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,13 @@
1
+ {
2
+ "name": "patchground-vitest-runtime",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "dependencies": {
7
+ "vitest": "5.0.5",
8
+ "vite": "8.3.5"
9
+ },
10
+ "scripts": {
11
+ "test": "node --check vitest-runner.mjs"
12
+ }
13
+ }
@@ -0,0 +1,124 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { mkdtemp, readFile, writeFile, rename, rm, lstat, realpath } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ const root = '/workspace';
8
+ const final = '/results/junit.xml';
9
+ const self = fileURLToPath(import.meta.url);
10
+
11
+ async function child(destination, files) {
12
+ const { startVitest } = await import('vitest/node');
13
+ let completed;
14
+ const reporter = {
15
+ onTestModuleCollected(module) {
16
+ for (const test of module.children.allTests()) {
17
+ const retry = test.options.retry;
18
+ const count = typeof retry === 'number' ? retry : retry?.count ?? 0;
19
+ if (count > 0 || (test.options.repeats ?? 0) > 0) {
20
+ throw Error('Retries and repeats are unsupported');
21
+ }
22
+ }
23
+ },
24
+ onTestRunEnd(modules, errors, reason) {
25
+ const cases = [];
26
+ let trustworthy = errors.length === 0 && reason !== 'interrupted';
27
+ for (const module of modules) {
28
+ if (module.errors().length) trustworthy = false;
29
+ for (const suite of module.children.allSuites()) {
30
+ if (suite.errors().length) trustworthy = false;
31
+ }
32
+ for (const test of module.children.allTests()) {
33
+ const state = test.result().state;
34
+ if (!['passed', 'failed', 'skipped'].includes(state)) trustworthy = false;
35
+ cases.push({ file: module.relativeModuleId, name: test.fullName, state });
36
+ }
37
+ }
38
+ completed = { trustworthy, cases };
39
+ },
40
+ };
41
+ const ctx = await startVitest([], {
42
+ root, config: false, watch: false, run: true,
43
+ include: files.length ? files : ['**/*.test.ts'], exclude: ['**/node_modules/**', '**/.git/**'],
44
+ reporters: [reporter], environment: 'node', pool: 'forks', maxWorkers: 1,
45
+ fileParallelism: false, isolate: true, retry: 0, allowOnly: false, maxConcurrency: 1,
46
+ passWithNoTests: false, testTimeout: 5000, hookTimeout: 5000,
47
+ sequence: { shuffle: false },
48
+ }, {
49
+ configFile: false, envDir: false, cacheDir: '/tmp/patchground-vite',
50
+ resolve: { alias: { vitest: '/opt/patchground/node_modules/vitest/dist/index.js' } },
51
+ });
52
+ await ctx.close();
53
+ if (!completed || ctx.state.getUnhandledErrors().length) return;
54
+ await writeFile(destination, JSON.stringify(completed), { flag: 'wx' });
55
+ }
56
+
57
+ function escape(value) {
58
+ return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')
59
+ .replaceAll('"', '&quot;').replaceAll("'", '&apos;').replaceAll("\n", "&#10;")
60
+ .replaceAll("\r", "&#13;").replaceAll("\t", "&#9;");
61
+ }
62
+
63
+ async function parent() {
64
+ await rm(final, { force: true });
65
+ const temporary = await mkdtemp(path.join(tmpdir(), 'patchground-vitest-'));
66
+ try {
67
+ const receipt = path.join(temporary, 'receipt.json');
68
+ const args = process.argv.slice(2);
69
+ const files = args.length ? args.slice(1) : [];
70
+ if (args.length && (args[0] !== '--files' || !files.length)) return;
71
+ for (const file of files) {
72
+ const parts = file.split('/');
73
+ if (!file.endsWith('.test.ts') || parts.some(part =>
74
+ !/^[\p{L}\p{N}_. -]+$/u.test(part) || ['.', '..', '.git', 'node_modules'].includes(part))) return;
75
+ }
76
+ if (new Set(files).size !== files.length) return;
77
+ for (const file of files) {
78
+ const full = path.join(root, file);
79
+ if (!(await lstat(full)).isFile() || !(await realpath(full)).startsWith(root + '/')) return;
80
+ }
81
+ const code = await new Promise((resolve, reject) => {
82
+ const process = spawn(globalThis.process.execPath, [self, '--child', receipt, ...files], {
83
+ stdio: 'ignore', env: { ...globalThis.process.env, CI: 'true' },
84
+ });
85
+ process.once('error', reject);
86
+ process.once('exit', (code, signal) => resolve(signal ? null : code));
87
+ });
88
+ const result = JSON.parse(await readFile(receipt, 'utf8'));
89
+ if (!result.trustworthy || !result.cases.length) return;
90
+ const failed = result.cases.some(test => test.state === 'failed');
91
+ if (code !== (failed ? 1 : 0)) return;
92
+ const seen = new Set();
93
+ result.cases.sort((a, b) => {
94
+ const left = `${a.file}::${a.name}`;
95
+ const right = `${b.file}::${b.name}`;
96
+ return left < right ? -1 : left > right ? 1 : 0;
97
+ });
98
+ const cases = result.cases.map(test => {
99
+ if (!test.file || path.posix.isAbsolute(test.file) || test.file.split('/').includes('..')
100
+ || test.file.includes('\\') || !test.file.endsWith('.test.ts') || !test.name
101
+ || !test.file.isWellFormed() || !test.name.isWellFormed()
102
+ || /[\u0000-\u0008\u000b\u000c\u000e-\u001f\ufffe\uffff]/u.test(test.file + test.name)) throw Error('unsafe identity');
103
+ const id = `${test.file}::${test.name}`;
104
+ if (seen.has(id)) throw Error('duplicate identity');
105
+ seen.add(id);
106
+ const status = test.state === 'failed' ? '<failure message="test failed"/>'
107
+ : test.state === 'skipped' ? '<skipped/>' : '';
108
+ return `<testcase classname="${escape(test.file)}" name="${escape(test.name)}">${status}</testcase>`;
109
+ });
110
+ const staging = '/results/.patchground-junit.xml';
111
+ await writeFile(staging, `<?xml version="1.0" encoding="UTF-8"?><testsuites><testsuite name="vitest">${cases.join('')}</testsuite></testsuites>`);
112
+ await rename(staging, final);
113
+ process.exitCode = failed ? 1 : 0;
114
+ } finally {
115
+ await rm(temporary, { recursive: true, force: true });
116
+ }
117
+ }
118
+
119
+ try {
120
+ if (process.argv[2] === '--child') await child(process.argv[3], process.argv.slice(4));
121
+ else { process.exitCode = 1; await parent(); }
122
+ } catch {
123
+ process.exitCode = 1;
124
+ }
@@ -0,0 +1,115 @@
1
+ """Shared descriptor-safe local access and non-executing Git executable selection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import stat
7
+ from collections.abc import Iterator
8
+ from contextlib import ExitStack, contextmanager
9
+ from pathlib import Path
10
+
11
+
12
+ class SafetyError(Exception):
13
+ """A local resource cannot be accessed safely."""
14
+
15
+
16
+ def no_links(
17
+ path: Path, *, error: type[Exception] = SafetyError, label: str = "filesystem"
18
+ ) -> None:
19
+ """Check ancestors even when the destination does not exist yet."""
20
+ for entry in (*reversed(path.absolute().parents), path.absolute()):
21
+ if entry.is_symlink():
22
+ raise error(f"{label} paths must not contain symlinks")
23
+
24
+
25
+ def safe_path(
26
+ path: Path, *, directory: bool, error: type[Exception] = SafetyError, label: str = "filesystem"
27
+ ) -> None:
28
+ """Reject redirected ancestors as well as redirected leaf entries."""
29
+ no_links(path, error=error, label=label)
30
+ mode = path.lstat().st_mode
31
+ if not (stat.S_ISDIR(mode) if directory else stat.S_ISREG(mode)):
32
+ raise error(f"{label} path has an invalid file type")
33
+
34
+
35
+ @contextmanager
36
+ def open_directory(
37
+ path: Path, *, error: type[Exception] = SafetyError, label: str = "filesystem"
38
+ ) -> Iterator[int]:
39
+ """Pin every ancestor, not just the leaf, without following any links."""
40
+ root = os.open(path.absolute().anchor, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
41
+ try:
42
+ with ExitStack() as ancestors:
43
+ descriptor = root
44
+ for part in path.absolute().parts[1:]:
45
+ descriptor = ancestors.enter_context(
46
+ open_entry(
47
+ descriptor, part, os.O_RDONLY | os.O_DIRECTORY, error=error, label=label
48
+ )
49
+ )
50
+ yield descriptor
51
+ except OSError as exc:
52
+ raise error(f"{label} directory contains a symlink or is unavailable") from exc
53
+ finally:
54
+ os.close(root)
55
+
56
+
57
+ @contextmanager
58
+ def open_entry(
59
+ parent: int,
60
+ name: str,
61
+ flags: int = os.O_RDONLY,
62
+ *,
63
+ error: type[Exception] = SafetyError,
64
+ label: str = "filesystem",
65
+ ) -> Iterator[int]:
66
+ """Validate the opened inode; NONBLOCK prevents a raced-in FIFO from hanging."""
67
+ before = os.stat(name, dir_fd=parent, follow_symlinks=False)
68
+ if not (stat.S_ISDIR(before.st_mode) or stat.S_ISREG(before.st_mode)):
69
+ raise error(f"{label} tree contains a symlink or special file")
70
+ try:
71
+ descriptor = os.open(name, flags | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=parent)
72
+ except OSError as exc:
73
+ raise error(f"{label} tree contains a symlink or special file or changed") from exc
74
+ try:
75
+ actual = os.fstat(descriptor)
76
+ if (before.st_dev, before.st_ino) != (actual.st_dev, actual.st_ino) or not (
77
+ stat.S_ISDIR(actual.st_mode) or stat.S_ISREG(actual.st_mode)
78
+ ):
79
+ raise error(f"{label} tree changed during access")
80
+ yield descriptor
81
+ after = os.stat(name, dir_fd=parent, follow_symlinks=False)
82
+ if (actual.st_dev, actual.st_ino) != (after.st_dev, after.st_ino):
83
+ raise error(f"{label} tree changed during access")
84
+ finally:
85
+ os.close(descriptor)
86
+
87
+
88
+ def resolve_git(*inputs: Path, error: type[Exception] = SafetyError) -> str:
89
+ """Trust only absolute PATH candidates outside cwd and all supplied input trees.
90
+
91
+ PATH is an installation search policy, not proof of executable provenance. Never
92
+ probe a candidate by running it. Canonicalize both the search path and target so
93
+ symlinks cannot disguise challenge input as an installed Git executable.
94
+ """
95
+ try:
96
+ roots: list[Path] = []
97
+ for path in (Path.cwd(), *inputs):
98
+ roots.extend((path.absolute(), path.resolve(strict=False)))
99
+ for directory in os.environ.get("PATH", os.defpath).split(os.pathsep):
100
+ if not Path(directory).is_absolute():
101
+ continue
102
+ candidate = Path(directory) / "git"
103
+ if any(candidate.is_relative_to(root) for root in roots):
104
+ continue
105
+ try:
106
+ executable = candidate.resolve(strict=True)
107
+ if any(executable.is_relative_to(root) for root in roots):
108
+ continue
109
+ if stat.S_ISREG(executable.stat().st_mode) and os.access(executable, os.X_OK):
110
+ return str(executable)
111
+ except (OSError, RuntimeError):
112
+ continue
113
+ except (OSError, RuntimeError):
114
+ pass
115
+ raise error("safe Git executable unavailable")
@@ -0,0 +1,39 @@
1
+ """patchground.sandbox — see SPEC.md in this folder.
2
+
3
+ The only module that talks to the container engine. Its API takes plain values (paths, strings,
4
+ limits) and returns plain results; it knows nothing about sessions, packs or reports.
5
+ """
6
+
7
+ from patchground.sandbox.build import BuildResult, build_image
8
+ from patchground.sandbox.errors import BuildFailed, EngineUnavailable, JUnitError, SandboxError
9
+ from patchground.sandbox.junit import TestResult, TestStatus, parse_junit
10
+ from patchground.sandbox.probe import EngineProbe, EngineProblem, probe_engine
11
+ from patchground.sandbox.run import (
12
+ CommandResult,
13
+ Limits,
14
+ RunOutcome,
15
+ RunResult,
16
+ run_check,
17
+ run_command,
18
+ )
19
+
20
+ __all__ = [
21
+ "BuildFailed",
22
+ "BuildResult",
23
+ "CommandResult",
24
+ "EngineProbe",
25
+ "EngineProblem",
26
+ "EngineUnavailable",
27
+ "JUnitError",
28
+ "Limits",
29
+ "RunOutcome",
30
+ "RunResult",
31
+ "SandboxError",
32
+ "TestResult",
33
+ "TestStatus",
34
+ "build_image",
35
+ "parse_junit",
36
+ "probe_engine",
37
+ "run_check",
38
+ "run_command",
39
+ ]
@@ -0,0 +1,23 @@
1
+ """Connection to the container engine (Docker, or Podman via its Docker-compatible socket)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import docker
6
+ import docker.errors
7
+ import requests
8
+
9
+ from patchground.sandbox.errors import EngineUnavailable
10
+
11
+
12
+ def connect() -> docker.DockerClient:
13
+ """Return a client for the engine configured in the environment, or raise EngineUnavailable."""
14
+ try:
15
+ client = docker.from_env()
16
+ except docker.errors.DockerException as exc:
17
+ raise EngineUnavailable(f"container engine not reachable: {exc}") from exc
18
+ try:
19
+ client.ping()
20
+ except (docker.errors.DockerException, requests.exceptions.RequestException) as exc:
21
+ client.close()
22
+ raise EngineUnavailable(f"container engine not reachable: {exc}") from exc
23
+ return client
@@ -0,0 +1,202 @@
1
+ """Build phase: build a challenge image from its environment directory.
2
+
3
+ The build has network access (dependencies install here, never at run time). Consent for untrusted
4
+ sources is the caller's job and must happen before :func:`build_image` is called.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import math
11
+ import os
12
+ import re
13
+ import stat
14
+ import time
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ import docker.errors
20
+ import requests
21
+ import urllib3.exceptions
22
+
23
+ from patchground.sandbox._engine import connect
24
+ from patchground.sandbox.errors import BuildFailed, SandboxError
25
+
26
+ #: Limits applied to every intermediate build container.
27
+ BUILD_MEMORY_BYTES = 4 * 1024**3
28
+
29
+ #: Default wall-clock budget for one image build.
30
+ DEFAULT_BUILD_TIMEOUT_SECONDS = 1800.0
31
+ #: A connection error this close to the deadline counts as the deadline being hit.
32
+ TIMEOUT_TOLERANCE_SECONDS = 1.0
33
+
34
+ _NAME_RE = re.compile(r"[a-z0-9]+(?:[._-][a-z0-9]+)*")
35
+ _BUILT_RE = re.compile(r"^Successfully built ([0-9a-f]+)$")
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class BuildResult:
40
+ """A built image: ``image_id`` is the engine's opaque id, ``tag`` the cache tag."""
41
+
42
+ image_id: str
43
+ tag: str
44
+ log: str
45
+
46
+
47
+ def context_hash(context: Path) -> str:
48
+ """Return a deterministic sha256 hex digest of a build context directory.
49
+
50
+ Covers every entry's relative path, kind, permission bits and content (symlinks by their
51
+ target text), in sorted path order, so the same tree always yields the same hash.
52
+ """
53
+ entries: list[tuple[str, Path]] = []
54
+ for dirpath, dirnames, filenames in os.walk(context):
55
+ dirnames.sort()
56
+ base = Path(dirpath)
57
+ for name in [*dirnames, *filenames]:
58
+ path = base / name
59
+ entries.append((path.relative_to(context).as_posix(), path))
60
+ digest = hashlib.sha256()
61
+ for rel, path in sorted(entries):
62
+ st = path.lstat()
63
+ mode = stat.S_IMODE(st.st_mode)
64
+ digest.update(rel.encode("utf-8", "surrogateescape") + b"\0")
65
+ if stat.S_ISLNK(st.st_mode):
66
+ digest.update(b"L\0" + str(path.readlink()).encode("utf-8", "surrogateescape"))
67
+ elif stat.S_ISDIR(st.st_mode):
68
+ digest.update(b"D\0" + f"{mode:o}".encode())
69
+ elif stat.S_ISREG(st.st_mode):
70
+ content = path.read_bytes()
71
+ digest.update(b"F\0" + f"{mode:o}\0{len(content)}\0".encode() + content)
72
+ else:
73
+ digest.update(b"O\0")
74
+ digest.update(b"\0")
75
+ return digest.hexdigest()
76
+
77
+
78
+ def _log_text(chunks: Any) -> str:
79
+ parts: list[str] = []
80
+ for chunk in chunks:
81
+ if not isinstance(chunk, dict):
82
+ parts.append(f"{chunk}\n")
83
+ continue
84
+ for key in ("stream", "error"):
85
+ value = chunk.get(key)
86
+ if isinstance(value, str):
87
+ parts.append(value if key == "stream" else f"{value}\n")
88
+ return "".join(parts)
89
+
90
+
91
+ def _note(log: str, note: str) -> str:
92
+ if log and not log.endswith("\n"):
93
+ log += "\n"
94
+ return f"{log}[patchground] {note}\n"
95
+
96
+
97
+ def build_image(
98
+ dockerfile: Path, *, name: str, timeout_seconds: float = DEFAULT_BUILD_TIMEOUT_SECONDS
99
+ ) -> BuildResult:
100
+ """Build ``dockerfile`` with its parent directory as the context.
101
+
102
+ The image is tagged ``patchground/<name>:<hash12>`` where ``hash12`` is the start of
103
+ :func:`context_hash` over the context directory.
104
+
105
+ Uses the Docker Engine API's classic (legacy, non-BuildKit) builder, which is what the
106
+ Docker SDK drives. No host mounts and no engine socket are exposed to the build; each build
107
+ container is capped at :data:`BUILD_MEMORY_BYTES` without swap.
108
+
109
+ ``timeout_seconds`` is the build's wall-clock budget. It is checked every time the builder
110
+ emits output, and the HTTP read timeout of the build request is set to the same value, so a
111
+ silent step (e.g. ``RUN sleep``) aborts the build once it has been quiet that long; a build
112
+ that goes quiet just before the deadline can therefore overrun it by up to one more
113
+ ``timeout_seconds``. Aborting closes the build connection, which makes the engine cancel the
114
+ build and remove its intermediate container.
115
+
116
+ Raises :class:`BuildFailed` (with the build log) if the build fails or exceeds
117
+ ``timeout_seconds``, :class:`EngineUnavailable` if the engine cannot be reached,
118
+ :class:`SandboxError` for engine-side failures (5xx, lost connection), and
119
+ :class:`ValueError` for an invalid ``name``, path or timeout.
120
+ """
121
+ if not _NAME_RE.fullmatch(name):
122
+ raise ValueError(
123
+ f"invalid image name {name!r}: use lowercase letters, digits, '.', '_', '-'"
124
+ )
125
+ if timeout_seconds <= 0:
126
+ raise ValueError(f"timeout_seconds must be positive: {timeout_seconds}")
127
+ if not dockerfile.is_file():
128
+ raise ValueError(f"Dockerfile not found: {dockerfile}")
129
+ context = dockerfile.parent.resolve()
130
+ tag = f"patchground/{name}:{context_hash(context)[:12]}"
131
+ exceeded = f"image build exceeded {timeout_seconds:g} s"
132
+ chunks: list[Any] = []
133
+ client = connect()
134
+ started = time.monotonic()
135
+ try:
136
+ stream = client.api.build(
137
+ path=str(context),
138
+ dockerfile=dockerfile.name,
139
+ tag=tag,
140
+ rm=True,
141
+ forcerm=True,
142
+ pull=False,
143
+ container_limits={"memory": BUILD_MEMORY_BYTES, "memswap": BUILD_MEMORY_BYTES},
144
+ labels={"patchground": "1"},
145
+ decode=True,
146
+ timeout=math.ceil(timeout_seconds),
147
+ )
148
+ image_id: str | None = None
149
+ for chunk in stream:
150
+ chunks.append(chunk)
151
+ if time.monotonic() - started > timeout_seconds:
152
+ raise BuildFailed(_note(_log_text(chunks), exceeded), message=exceeded)
153
+ if not isinstance(chunk, dict):
154
+ continue
155
+ if "error" in chunk:
156
+ raise BuildFailed(_log_text(chunks))
157
+ image_id = _image_id(chunk) or image_id
158
+ log = _log_text(chunks)
159
+ if image_id is None:
160
+ raise BuildFailed(_note(log, "the builder reported no image id"))
161
+ image = client.images.get(image_id)
162
+ except (
163
+ requests.exceptions.ReadTimeout,
164
+ requests.exceptions.ConnectionError,
165
+ urllib3.exceptions.ReadTimeoutError,
166
+ urllib3.exceptions.ProtocolError,
167
+ ) as exc:
168
+ # A silent build step outlasting the read timeout surfaces as ReadTimeout/ConnectionError
169
+ # (docker/docker-py#1966) or, while the log streams, as urllib3's ReadTimeoutError.
170
+ # Well before the deadline it is a lost engine instead.
171
+ if time.monotonic() - started >= timeout_seconds - TIMEOUT_TOLERANCE_SECONDS:
172
+ raise BuildFailed(_note(_log_text(chunks), exceeded), message=exceeded) from exc
173
+ raise SandboxError(f"engine connection lost during build: {exc}") from exc
174
+ except docker.errors.APIError as exc:
175
+ status = exc.status_code
176
+ if status is not None and 400 <= status < 500:
177
+ raise BuildFailed(_note(_log_text(chunks), str(exc))) from exc
178
+ raise SandboxError(f"image build did not complete: {exc}") from exc
179
+ except (
180
+ docker.errors.DockerException,
181
+ requests.exceptions.RequestException,
182
+ urllib3.exceptions.HTTPError,
183
+ ) as exc:
184
+ raise SandboxError(f"image build did not complete: {exc}") from exc
185
+ finally:
186
+ client.close()
187
+ if image.id is None:
188
+ raise SandboxError("engine returned an image without an id")
189
+ return BuildResult(image_id=image.id, tag=tag, log=log)
190
+
191
+
192
+ def _image_id(chunk: dict[str, Any]) -> str | None:
193
+ """The image id announced by a build chunk (``aux.ID`` or "Successfully built <id>")."""
194
+ aux = chunk.get("aux")
195
+ if isinstance(aux, dict) and isinstance(aux.get("ID"), str):
196
+ return str(aux["ID"])
197
+ stream = chunk.get("stream")
198
+ if isinstance(stream, str):
199
+ match = _BUILT_RE.search(stream.strip())
200
+ if match:
201
+ return match.group(1)
202
+ return None
@@ -0,0 +1,23 @@
1
+ """Exceptions raised by the sandbox module."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class SandboxError(Exception):
7
+ """Base class for every sandbox failure that is not a test outcome."""
8
+
9
+
10
+ class EngineUnavailable(SandboxError):
11
+ """The container engine cannot be reached (not running, no socket, no permission)."""
12
+
13
+
14
+ class BuildFailed(SandboxError):
15
+ """The image build failed; ``log`` holds the builder output up to the failure."""
16
+
17
+ def __init__(self, log: str, *, message: str = "image build failed") -> None:
18
+ super().__init__(message)
19
+ self.log = log
20
+
21
+
22
+ class JUnitError(SandboxError):
23
+ """A JUnit XML document could not be parsed (malformed, unsafe or wrong root)."""
@@ -0,0 +1,76 @@
1
+ """JUnit XML parsing into per-test results.
2
+
3
+ The XML comes from inside a challenge container and is therefore untrusted: it is always parsed
4
+ with ``defusedxml`` (DTDs forbidden outright, which also rules out entity-expansion and XXE
5
+ payloads) and only then handed to ``junitparser`` for the JUnit structure.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import io
11
+ from dataclasses import dataclass
12
+ from enum import StrEnum
13
+ from typing import IO
14
+ from xml.etree.ElementTree import ElementTree, ParseError
15
+
16
+ import defusedxml
17
+ import defusedxml.ElementTree
18
+ from junitparser import Error, Failure, JUnitXml, JUnitXmlError, Skipped
19
+
20
+ from patchground.sandbox.errors import JUnitError
21
+
22
+
23
+ class TestStatus(StrEnum):
24
+ """Outcome of a single test case."""
25
+
26
+ __test__ = False # not a pytest test class
27
+
28
+ PASSED = "passed"
29
+ FAILED = "failed"
30
+ ERRORED = "errored"
31
+ SKIPPED = "skipped"
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class TestResult:
36
+ """One ``<testcase>``; ``id`` is ``classname::name`` (or ``name`` without a classname)."""
37
+
38
+ __test__ = False # not a pytest test class
39
+
40
+ id: str
41
+ name: str
42
+ classname: str
43
+ status: TestStatus
44
+
45
+
46
+ def _safe_parse(source: IO[bytes]) -> ElementTree:
47
+ return defusedxml.ElementTree.parse(source, forbid_dtd=True)
48
+
49
+
50
+ def parse_junit(data: bytes) -> tuple[TestResult, ...]:
51
+ """Parse a JUnit XML document (``<testsuites>`` or ``<testsuite>`` root).
52
+
53
+ Status precedence inside one test case: error > failure > skipped > passed.
54
+ Raises :class:`JUnitError` if the document is malformed, unsafe or not JUnit.
55
+ """
56
+ try:
57
+ xml = JUnitXml.fromfile(io.BytesIO(data), parse_func=_safe_parse)
58
+ results: list[TestResult] = []
59
+ for suite in xml:
60
+ for case in suite:
61
+ name = str(case.name or "")
62
+ classname = str(case.classname or "")
63
+ outcomes = case.result
64
+ if any(isinstance(r, Error) for r in outcomes):
65
+ status = TestStatus.ERRORED
66
+ elif any(isinstance(r, Failure) for r in outcomes):
67
+ status = TestStatus.FAILED
68
+ elif any(isinstance(r, Skipped) for r in outcomes):
69
+ status = TestStatus.SKIPPED
70
+ else:
71
+ status = TestStatus.PASSED
72
+ test_id = f"{classname}::{name}" if classname else name
73
+ results.append(TestResult(test_id, name, classname, status))
74
+ except (ParseError, defusedxml.DefusedXmlException, JUnitXmlError) as exc:
75
+ raise JUnitError(f"invalid JUnit XML: {exc}") from exc
76
+ return tuple(results)