codeterrain 0.3.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.
- codeterrain/__init__.py +3 -0
- codeterrain/_guard.py +243 -0
- codeterrain/_pytest_dump_plugin.py +253 -0
- codeterrain/_routes_runner.py +119 -0
- codeterrain/budget.py +128 -0
- codeterrain/cache.py +618 -0
- codeterrain/calls_join.py +416 -0
- codeterrain/calls_static.py +1099 -0
- codeterrain/cli.py +1447 -0
- codeterrain/code_static.py +2411 -0
- codeterrain/config.py +331 -0
- codeterrain/executables.py +130 -0
- codeterrain/fixtures_order.py +77 -0
- codeterrain/fixtures_static.py +501 -0
- codeterrain/hookspecs.py +25 -0
- codeterrain/impact.py +725 -0
- codeterrain/languages.py +293 -0
- codeterrain/plugins/__init__.py +0 -0
- codeterrain/plugins/fastapi_routes.py +193 -0
- codeterrain/plugins/manifest_join.py +107 -0
- codeterrain/protocol.py +138 -0
- codeterrain/routes_view.py +61 -0
- codeterrain/sandbox.py +565 -0
- codeterrain/views.py +198 -0
- codeterrain-0.3.0.dist-info/METADATA +295 -0
- codeterrain-0.3.0.dist-info/RECORD +30 -0
- codeterrain-0.3.0.dist-info/WHEEL +4 -0
- codeterrain-0.3.0.dist-info/entry_points.txt +2 -0
- codeterrain-0.3.0.dist-info/licenses/LICENSE +201 -0
- codeterrain-0.3.0.dist-info/licenses/NOTICE +4 -0
codeterrain/sandbox.py
ADDED
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
"""The only place codeterrain starts a subprocess that imports or runs the target's code.
|
|
2
|
+
|
|
3
|
+
Importing an application can call AWS (or any network service) at import time, so every such
|
|
4
|
+
subprocess runs with:
|
|
5
|
+
|
|
6
|
+
- an environment built from an allowlist (PATH, locale and temp variables, plus the Windows
|
|
7
|
+
basics), never the parent's whole environment; the config's ``import_env`` and the opt-in
|
|
8
|
+
``pass_env`` are layered on top;
|
|
9
|
+
- the home and per-user data directories (``HOME``, ``USERPROFILE``, ``APPDATA``,
|
|
10
|
+
``LOCALAPPDATA``, ``XDG_*``) pointed at an empty directory in the run dir, so SDKs that look for
|
|
11
|
+
credential files there find none; only user-level ``pass_env`` restores them. Code that reads
|
|
12
|
+
the real home by absolute path, or asks an OS credential store, is not stopped;
|
|
13
|
+
- git told to read no system or global config and to use no credential helper, so a ``git`` the
|
|
14
|
+
target starts is not authenticated with the user's token (Git Credential Manager is set in the
|
|
15
|
+
system config by Git for Windows);
|
|
16
|
+
- dummy AWS keys, and the shared credentials/config files pointed at a path that does not exist;
|
|
17
|
+
neither config key can bring an ``AWS_*`` credential back;
|
|
18
|
+
- every ``*_proxy`` variable dropped and ``NO_PROXY=*``; ``PYTEST_ADDOPTS`` and ``PYTEST_PLUGINS``
|
|
19
|
+
dropped;
|
|
20
|
+
- a best-effort network guard (``codeterrain._guard``): sockets reach loopback only, any other
|
|
21
|
+
connect, send or name lookup raises. The extractor is started through a bootstrap that installs
|
|
22
|
+
the guard, asserts it is active (exit 3 otherwise) and only then runs the extractor module;
|
|
23
|
+
- results written to a JSON file named by ``CODETERRAIN_RESULT``, never parsed from stdout.
|
|
24
|
+
|
|
25
|
+
The package is copied into a fresh per-run directory under the system temp dir (never the cache
|
|
26
|
+
dir, so the child is not told where the cache is) that goes first on ``PYTHONPATH``; the host's
|
|
27
|
+
own site-packages is never exposed to the target interpreter. The child's stdout and stderr go to
|
|
28
|
+
files in that directory and every read back is capped. On timeout the whole process tree is
|
|
29
|
+
killed; after a normal exit the processes the child left behind are killed too (a job object on
|
|
30
|
+
Windows, the process group on POSIX).
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import json
|
|
36
|
+
import os
|
|
37
|
+
import shutil
|
|
38
|
+
import signal
|
|
39
|
+
import subprocess
|
|
40
|
+
import sys
|
|
41
|
+
import tempfile
|
|
42
|
+
import time
|
|
43
|
+
from collections.abc import Callable
|
|
44
|
+
from dataclasses import dataclass
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
from typing import Any
|
|
47
|
+
|
|
48
|
+
from codeterrain.config import Config
|
|
49
|
+
from codeterrain.executables import resolve_executable
|
|
50
|
+
|
|
51
|
+
DUMMY_CREDENTIAL = "codeterrain-dummy"
|
|
52
|
+
RESULT_ENV = "CODETERRAIN_RESULT"
|
|
53
|
+
GUARD_MARKER = "codeterrain sandbox"
|
|
54
|
+
DEFAULT_TIMEOUT = 1800
|
|
55
|
+
#: The largest result document read back from a child.
|
|
56
|
+
MAX_RESULT_BYTES = 256 * 1024 * 1024
|
|
57
|
+
#: Seconds to wait for a killed process tree to go.
|
|
58
|
+
KILL_GRACE = 10
|
|
59
|
+
|
|
60
|
+
GUARD_FAILED = "codeterrain sandbox: network guard not active"
|
|
61
|
+
GUARD_EXIT = 3
|
|
62
|
+
RUN_DIR_ENV = "CODETERRAIN_RUN_DIR"
|
|
63
|
+
RESPECT_ADDOPTS_ENV = "CODETERRAIN_RESPECT_ADDOPTS"
|
|
64
|
+
|
|
65
|
+
SITECUSTOMIZE = """\
|
|
66
|
+
try:
|
|
67
|
+
import codeterrain._guard as _guard
|
|
68
|
+
|
|
69
|
+
_guard.install()
|
|
70
|
+
except Exception:
|
|
71
|
+
pass
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
BOOTSTRAP = r"""\
|
|
75
|
+
import os, sys
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _fail(reason):
|
|
79
|
+
sys.stderr.write("codeterrain sandbox: network guard not active (%s)\n" % reason)
|
|
80
|
+
sys.exit(3)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
run_dir = os.environ["CODETERRAIN_RUN_DIR"]
|
|
85
|
+
sys.path.insert(0, run_dir)
|
|
86
|
+
import codeterrain._guard as guard
|
|
87
|
+
|
|
88
|
+
here = os.path.realpath(os.path.dirname(guard.__file__))
|
|
89
|
+
root = os.path.realpath(run_dir)
|
|
90
|
+
try:
|
|
91
|
+
inside = os.path.commonpath([here, root]) == root
|
|
92
|
+
except ValueError:
|
|
93
|
+
inside = False
|
|
94
|
+
if not inside:
|
|
95
|
+
_fail("guard module loaded from %s" % here)
|
|
96
|
+
guard.install()
|
|
97
|
+
guard.use_selector_event_loop()
|
|
98
|
+
import socket
|
|
99
|
+
|
|
100
|
+
if socket.socket.connect is not guard.connect:
|
|
101
|
+
_fail("socket.socket.connect was not replaced")
|
|
102
|
+
if not guard.proactor_guarded():
|
|
103
|
+
_fail("asyncio proactor was not wrapped")
|
|
104
|
+
except SystemExit:
|
|
105
|
+
raise
|
|
106
|
+
except BaseException as exc:
|
|
107
|
+
_fail("%s: %s" % (type(exc).__name__, exc))
|
|
108
|
+
|
|
109
|
+
import runpy
|
|
110
|
+
|
|
111
|
+
mode, target, rest = sys.argv[1], sys.argv[2], sys.argv[3:]
|
|
112
|
+
if mode == "-m":
|
|
113
|
+
sys.argv = [target] + rest
|
|
114
|
+
runpy.run_module(target, run_name="__main__", alter_sys=True)
|
|
115
|
+
else:
|
|
116
|
+
sys.argv = ["-c"] + rest
|
|
117
|
+
exec(compile(target, "<string>", "exec"), {"__name__": "__main__"})
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
# Always allowed through from the parent environment.
|
|
121
|
+
ENV_ALLOWLIST = frozenset({"PATH", "LANG", "LC_ALL", "TMPDIR", "TEMP", "TMP"})
|
|
122
|
+
WINDOWS_ENV_ALLOWLIST = frozenset(
|
|
123
|
+
{
|
|
124
|
+
"SYSTEMROOT",
|
|
125
|
+
"SYSTEMDRIVE",
|
|
126
|
+
"WINDIR",
|
|
127
|
+
"COMSPEC",
|
|
128
|
+
"PATHEXT",
|
|
129
|
+
"NUMBER_OF_PROCESSORS",
|
|
130
|
+
"PROCESSOR_ARCHITECTURE",
|
|
131
|
+
"USERNAME",
|
|
132
|
+
"PROGRAMDATA",
|
|
133
|
+
"PROGRAMFILES",
|
|
134
|
+
"PROGRAMFILES(X86)",
|
|
135
|
+
}
|
|
136
|
+
)
|
|
137
|
+
# The home and per-user data directories, pointed at an empty directory in the run dir so the
|
|
138
|
+
# child sees no credential store (gh, gcloud, az, git, netrc, keyring files); user-level
|
|
139
|
+
# ``pass_env`` can hand any of them through on purpose.
|
|
140
|
+
HOME_DIRS = {
|
|
141
|
+
"HOME": "home",
|
|
142
|
+
"USERPROFILE": "home",
|
|
143
|
+
"APPDATA": "home/AppData/Roaming",
|
|
144
|
+
"LOCALAPPDATA": "home/AppData/Local",
|
|
145
|
+
"XDG_CONFIG_HOME": "home/.config",
|
|
146
|
+
"XDG_CACHE_HOME": "home/.cache",
|
|
147
|
+
"XDG_DATA_HOME": "home/.local/share",
|
|
148
|
+
}
|
|
149
|
+
# git reads no system or global config and has no credential helper, so a git the target starts
|
|
150
|
+
# cannot use the user's stored token. An empty ``credential.helper`` resets the helper list, and
|
|
151
|
+
# the command-line scope (``GIT_CONFIG_COUNT``) is read last. User-level ``pass_env`` can override.
|
|
152
|
+
GIT_SANDBOX_ENV = {
|
|
153
|
+
"GIT_CONFIG_NOSYSTEM": "1",
|
|
154
|
+
"GIT_TERMINAL_PROMPT": "0",
|
|
155
|
+
"GCM_INTERACTIVE": "never",
|
|
156
|
+
"GIT_CONFIG_COUNT": "1",
|
|
157
|
+
"GIT_CONFIG_KEY_0": "credential.helper",
|
|
158
|
+
"GIT_CONFIG_VALUE_0": "",
|
|
159
|
+
}
|
|
160
|
+
# Never reach the target, whichever config key asks for them.
|
|
161
|
+
ENV_ALWAYS_DROPPED = frozenset({"PYTEST_ADDOPTS", "PYTEST_PLUGINS"})
|
|
162
|
+
WINDOWS = os.name == "nt"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class SandboxError(RuntimeError):
|
|
166
|
+
"""The sandboxed subprocess could not be run to completion."""
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@dataclass
|
|
170
|
+
class SandboxResult:
|
|
171
|
+
returncode: int
|
|
172
|
+
stdout: str
|
|
173
|
+
stderr: str
|
|
174
|
+
result: Any | None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def credential_env(run_dir: Path, region: str) -> dict[str, str]:
|
|
178
|
+
"""The AWS environment every sandboxed process gets: dummy keys, no files, no metadata."""
|
|
179
|
+
return {
|
|
180
|
+
"AWS_ACCESS_KEY_ID": DUMMY_CREDENTIAL,
|
|
181
|
+
"AWS_SECRET_ACCESS_KEY": DUMMY_CREDENTIAL,
|
|
182
|
+
"AWS_SHARED_CREDENTIALS_FILE": str(run_dir / "no-such-credentials"),
|
|
183
|
+
"AWS_CONFIG_FILE": str(run_dir / "no-such-config"),
|
|
184
|
+
"AWS_EC2_METADATA_DISABLED": "true",
|
|
185
|
+
"AWS_DEFAULT_REGION": region,
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
#: Shorter values are not redacted: they hold no meaningful secret, and replacing a value such as
|
|
190
|
+
#: ``test`` or ``1`` would corrupt every path and traceback that contains it.
|
|
191
|
+
MIN_REDACTED_LENGTH = 6
|
|
192
|
+
REDACTED = "[REDACTED]"
|
|
193
|
+
#: A run of this many characters or more from the start or the end of a longer value is redacted
|
|
194
|
+
#: too: libraries print a long value shortened (pydantic keeps both ends around ``...``).
|
|
195
|
+
MIN_FRAGMENT_LENGTH = 12
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _end_fragments(text: str, secret: str) -> list[tuple[int, int]]:
|
|
199
|
+
"""Spans of ``text`` that are a prefix or a suffix of ``secret`` of at least
|
|
200
|
+
``MIN_FRAGMENT_LENGTH`` characters, each grown as far as it keeps matching."""
|
|
201
|
+
n = MIN_FRAGMENT_LENGTH
|
|
202
|
+
spans = []
|
|
203
|
+
start = text.find(secret[:n])
|
|
204
|
+
while start != -1:
|
|
205
|
+
end = start + n
|
|
206
|
+
while end - start < len(secret) and end < len(text) and text[end] == secret[end - start]:
|
|
207
|
+
end += 1
|
|
208
|
+
spans.append((start, end))
|
|
209
|
+
start = text.find(secret[:n], end)
|
|
210
|
+
stop = text.find(secret[-n:])
|
|
211
|
+
while stop != -1:
|
|
212
|
+
begin, k = stop, len(secret) - n
|
|
213
|
+
while k > 0 and begin > 0 and text[begin - 1] == secret[k - 1]:
|
|
214
|
+
begin, k = begin - 1, k - 1
|
|
215
|
+
spans.append((begin, stop + n))
|
|
216
|
+
stop = text.find(secret[-n:], stop + 1)
|
|
217
|
+
return spans
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _replace_spans(text: str, spans: list[tuple[int, int]]) -> str:
|
|
221
|
+
out, last = [], 0
|
|
222
|
+
for begin, end in sorted(spans):
|
|
223
|
+
if end <= last:
|
|
224
|
+
continue
|
|
225
|
+
out.append(text[last:max(begin, last)])
|
|
226
|
+
out.append(REDACTED if begin >= last else "")
|
|
227
|
+
last = end
|
|
228
|
+
return "".join(out) + text[last:]
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def redactor(config: Config, source: dict[str, str] | None = None) -> Callable[[str], str]:
|
|
232
|
+
"""A function that replaces every ``import_env`` value, every ``pass_env`` value present in
|
|
233
|
+
the parent environment and the dummy credential with ``[REDACTED]``, and a long value's
|
|
234
|
+
leading and trailing fragments too (``MIN_FRAGMENT_LENGTH``). Child text (errors, tracebacks,
|
|
235
|
+
output tails, node ids, route fields) goes through it before it is cached or printed."""
|
|
236
|
+
env = os.environ if source is None else source
|
|
237
|
+
values = {*config.import_env.values(), DUMMY_CREDENTIAL}
|
|
238
|
+
values.update(env[name] for name in config.pass_env if name in env)
|
|
239
|
+
secrets = sorted((v for v in values if len(v) >= MIN_REDACTED_LENGTH), key=len, reverse=True)
|
|
240
|
+
|
|
241
|
+
def redact(text: str) -> str:
|
|
242
|
+
for secret in secrets:
|
|
243
|
+
text = text.replace(secret, REDACTED)
|
|
244
|
+
spans = [
|
|
245
|
+
span
|
|
246
|
+
for secret in secrets
|
|
247
|
+
if len(secret) > MIN_FRAGMENT_LENGTH
|
|
248
|
+
for span in _end_fragments(text, secret)
|
|
249
|
+
]
|
|
250
|
+
return _replace_spans(text, spans) if spans else text
|
|
251
|
+
|
|
252
|
+
return redact
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def redact_document(value: Any, redact: Callable[[str], str]) -> Any:
|
|
256
|
+
"""``value`` with every string in it, object keys included, passed through ``redact``; other
|
|
257
|
+
types are kept. A child document can carry optional or plugin fields the core never reads."""
|
|
258
|
+
if isinstance(value, str):
|
|
259
|
+
return redact(value)
|
|
260
|
+
if isinstance(value, list):
|
|
261
|
+
return [redact_document(v, redact) for v in value]
|
|
262
|
+
if isinstance(value, dict):
|
|
263
|
+
return {redact(k) if isinstance(k, str) else k: redact_document(v, redact)
|
|
264
|
+
for k, v in value.items()}
|
|
265
|
+
return value
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _allowed(name: str) -> bool:
|
|
269
|
+
if name in ENV_ALLOWLIST:
|
|
270
|
+
return True
|
|
271
|
+
return WINDOWS and name.upper() in WINDOWS_ENV_ALLOWLIST
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _is_proxy(name: str) -> bool:
|
|
275
|
+
return name.lower().endswith("_proxy")
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def build_env(
|
|
279
|
+
config: Config,
|
|
280
|
+
run_dir: Path,
|
|
281
|
+
result_path: Path,
|
|
282
|
+
base: dict[str, str] | None = None,
|
|
283
|
+
) -> dict[str, str]:
|
|
284
|
+
source = os.environ if base is None else base
|
|
285
|
+
env = {k: v for k, v in source.items() if _allowed(k)}
|
|
286
|
+
aws = credential_env(run_dir, config.region)
|
|
287
|
+
env.update(aws)
|
|
288
|
+
env.update(config.import_env)
|
|
289
|
+
env.update({name: str(run_dir / rel) for name, rel in HOME_DIRS.items()})
|
|
290
|
+
env.update(GIT_SANDBOX_ENV, GIT_CONFIG_GLOBAL=str(run_dir / "no-such-gitconfig"))
|
|
291
|
+
for name in config.pass_env:
|
|
292
|
+
upper = name.upper()
|
|
293
|
+
if name in source and (not upper.startswith("AWS_") or upper == "AWS_DEFAULT_REGION"):
|
|
294
|
+
env[name] = source[name]
|
|
295
|
+
# import_env and pass_env may tune the region but can never bring real credentials back.
|
|
296
|
+
env.update({k: v for k, v in aws.items() if k != "AWS_DEFAULT_REGION"})
|
|
297
|
+
for name in list(env):
|
|
298
|
+
if _is_proxy(name) or name.upper() in ENV_ALWAYS_DROPPED:
|
|
299
|
+
del env[name]
|
|
300
|
+
env.update(
|
|
301
|
+
{
|
|
302
|
+
"NO_PROXY": "*",
|
|
303
|
+
"no_proxy": "*",
|
|
304
|
+
"PYTHONPATH": os.pathsep.join(p for p in (str(run_dir), env.get("PYTHONPATH")) if p),
|
|
305
|
+
"PYTHONDONTWRITEBYTECODE": "1",
|
|
306
|
+
"COVERAGE_FILE": str(run_dir / ".coverage"),
|
|
307
|
+
RESULT_ENV: str(result_path),
|
|
308
|
+
RUN_DIR_ENV: str(run_dir),
|
|
309
|
+
"CODETERRAIN_ROOT": config.root.as_posix(),
|
|
310
|
+
}
|
|
311
|
+
)
|
|
312
|
+
if config.respect_addopts:
|
|
313
|
+
env[RESPECT_ADDOPTS_ENV] = "1"
|
|
314
|
+
return env
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _tail(text: str, limit: int = 4000) -> str:
|
|
318
|
+
return text if len(text) <= limit else "..." + text[-limit:]
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _read_tail(path: Path, limit: int = 4000) -> str:
|
|
322
|
+
"""The last ``limit`` characters of a child's output file, read without loading all of it."""
|
|
323
|
+
try:
|
|
324
|
+
with open(path, "rb") as handle:
|
|
325
|
+
size = handle.seek(0, os.SEEK_END)
|
|
326
|
+
handle.seek(max(0, size - 4 * limit))
|
|
327
|
+
data = handle.read()
|
|
328
|
+
except OSError:
|
|
329
|
+
return ""
|
|
330
|
+
text = data.decode("utf-8", errors="replace")
|
|
331
|
+
return "..." + text[-limit:] if len(data) < size else _tail(text, limit)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _warn(message: str) -> None:
|
|
335
|
+
print(f"warning: {message}", file=sys.stderr)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
class _Job:
|
|
339
|
+
"""Windows only: a job object holding the child and everything it starts, orphans included.
|
|
340
|
+
|
|
341
|
+
``JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`` makes closing it kill every process still in it. The
|
|
342
|
+
child is assigned straight after it starts, before its interpreter has run any target code.
|
|
343
|
+
"""
|
|
344
|
+
|
|
345
|
+
def __init__(self, proc: subprocess.Popen[bytes]) -> None:
|
|
346
|
+
import ctypes
|
|
347
|
+
from ctypes import wintypes
|
|
348
|
+
|
|
349
|
+
class _Basic(ctypes.Structure):
|
|
350
|
+
_fields_ = [
|
|
351
|
+
("PerProcessUserTimeLimit", ctypes.c_int64),
|
|
352
|
+
("PerJobUserTimeLimit", ctypes.c_int64),
|
|
353
|
+
("LimitFlags", wintypes.DWORD),
|
|
354
|
+
("MinimumWorkingSetSize", ctypes.c_size_t),
|
|
355
|
+
("MaximumWorkingSetSize", ctypes.c_size_t),
|
|
356
|
+
("ActiveProcessLimit", wintypes.DWORD),
|
|
357
|
+
("Affinity", ctypes.c_size_t),
|
|
358
|
+
("PriorityClass", wintypes.DWORD),
|
|
359
|
+
("SchedulingClass", wintypes.DWORD),
|
|
360
|
+
]
|
|
361
|
+
|
|
362
|
+
class _Extended(ctypes.Structure):
|
|
363
|
+
_fields_ = [
|
|
364
|
+
("BasicLimitInformation", _Basic),
|
|
365
|
+
("IoInfo", ctypes.c_uint64 * 6),
|
|
366
|
+
("ProcessMemoryLimit", ctypes.c_size_t),
|
|
367
|
+
("JobMemoryLimit", ctypes.c_size_t),
|
|
368
|
+
("PeakProcessMemoryUsed", ctypes.c_size_t),
|
|
369
|
+
("PeakJobMemoryUsed", ctypes.c_size_t),
|
|
370
|
+
]
|
|
371
|
+
|
|
372
|
+
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
373
|
+
k32.CreateJobObjectW.restype = wintypes.HANDLE
|
|
374
|
+
k32.CreateJobObjectW.argtypes = (wintypes.LPVOID, wintypes.LPCWSTR)
|
|
375
|
+
k32.SetInformationJobObject.argtypes = (
|
|
376
|
+
wintypes.HANDLE, ctypes.c_int, wintypes.LPVOID, wintypes.DWORD
|
|
377
|
+
)
|
|
378
|
+
k32.AssignProcessToJobObject.argtypes = (wintypes.HANDLE, wintypes.HANDLE)
|
|
379
|
+
k32.TerminateJobObject.argtypes = (wintypes.HANDLE, wintypes.UINT)
|
|
380
|
+
k32.CloseHandle.argtypes = (wintypes.HANDLE,)
|
|
381
|
+
self._k32 = k32
|
|
382
|
+
self.handle = k32.CreateJobObjectW(None, None)
|
|
383
|
+
if not self.handle:
|
|
384
|
+
raise OSError(ctypes.get_last_error(), "CreateJobObjectW failed")
|
|
385
|
+
info = _Extended()
|
|
386
|
+
info.BasicLimitInformation.LimitFlags = 0x2000 # JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
|
|
387
|
+
ok = k32.SetInformationJobObject(self.handle, 9, ctypes.byref(info), ctypes.sizeof(info))
|
|
388
|
+
if ok:
|
|
389
|
+
ok = k32.AssignProcessToJobObject(self.handle, int(proc._handle)) # type: ignore[attr-defined]
|
|
390
|
+
if not ok:
|
|
391
|
+
error = ctypes.get_last_error()
|
|
392
|
+
k32.CloseHandle(self.handle)
|
|
393
|
+
raise OSError(error, "cannot put the child in a job object")
|
|
394
|
+
|
|
395
|
+
def close(self) -> None:
|
|
396
|
+
"""Kill every process left in the job and release it."""
|
|
397
|
+
if self.handle:
|
|
398
|
+
self._k32.TerminateJobObject(self.handle, 1)
|
|
399
|
+
self._k32.CloseHandle(self.handle)
|
|
400
|
+
self.handle = None
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _new_job(proc: subprocess.Popen[bytes]) -> _Job | None:
|
|
404
|
+
try:
|
|
405
|
+
return _Job(proc)
|
|
406
|
+
except OSError as exc:
|
|
407
|
+
_warn(f"processes the sandboxed child starts may outlive it: {exc}")
|
|
408
|
+
return None
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _kill_tree(proc: subprocess.Popen[bytes], job: _Job | None = None) -> None:
|
|
412
|
+
"""Kill the child and everything it started, then wait a bounded time for it to go.
|
|
413
|
+
|
|
414
|
+
Windows: the job object when there is one, else ``taskkill /T /F`` by its absolute path under
|
|
415
|
+
``%SystemRoot%``. POSIX: the child leads its own session (``start_new_session``), so the whole
|
|
416
|
+
process group is killed. On POSIX a grandchild that started its own session survives.
|
|
417
|
+
"""
|
|
418
|
+
if job is not None:
|
|
419
|
+
job.close()
|
|
420
|
+
elif WINDOWS:
|
|
421
|
+
system_root = os.environ.get("SYSTEMROOT") or r"C:\Windows"
|
|
422
|
+
taskkill = Path(system_root) / "System32" / "taskkill.exe"
|
|
423
|
+
try:
|
|
424
|
+
subprocess.run(
|
|
425
|
+
[str(taskkill), "/T", "/F", "/PID", str(proc.pid)],
|
|
426
|
+
stdin=subprocess.DEVNULL,
|
|
427
|
+
stdout=subprocess.DEVNULL,
|
|
428
|
+
stderr=subprocess.DEVNULL,
|
|
429
|
+
timeout=KILL_GRACE,
|
|
430
|
+
check=False,
|
|
431
|
+
)
|
|
432
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
433
|
+
_warn(f"could not kill the sandboxed process tree: {exc}")
|
|
434
|
+
else:
|
|
435
|
+
_kill_group(proc.pid)
|
|
436
|
+
proc.kill()
|
|
437
|
+
try:
|
|
438
|
+
proc.wait(timeout=KILL_GRACE)
|
|
439
|
+
except subprocess.TimeoutExpired:
|
|
440
|
+
pass
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _kill_group(pid: int) -> None:
|
|
444
|
+
"""POSIX only: kill the child's process group, stragglers included; gone is fine."""
|
|
445
|
+
try:
|
|
446
|
+
os.killpg(pid, signal.SIGKILL)
|
|
447
|
+
except (ProcessLookupError, PermissionError):
|
|
448
|
+
pass
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _remove_run_dir(run_dir: Path) -> None:
|
|
452
|
+
"""Remove the run dir; on Windows a just-killed process can hold its files for a moment."""
|
|
453
|
+
for _ in range(10):
|
|
454
|
+
shutil.rmtree(run_dir, ignore_errors=True)
|
|
455
|
+
if not run_dir.exists():
|
|
456
|
+
return
|
|
457
|
+
time.sleep(0.2)
|
|
458
|
+
_warn(f"could not remove the sandbox run directory {run_dir}")
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _read_result(path: Path) -> Any | None:
|
|
462
|
+
if not path.is_file():
|
|
463
|
+
return None
|
|
464
|
+
size = path.stat().st_size
|
|
465
|
+
if size > MAX_RESULT_BYTES:
|
|
466
|
+
raise SandboxError(f"result file is {size} bytes, over the {MAX_RESULT_BYTES} byte cap")
|
|
467
|
+
try:
|
|
468
|
+
return json.loads(path.read_bytes())
|
|
469
|
+
except (ValueError, RecursionError) as exc:
|
|
470
|
+
raise SandboxError(f"unreadable result file: {type(exc).__name__}: {exc}") from exc
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _stage(run_dir: Path) -> None:
|
|
474
|
+
"""Fill the per-run directory: a copy of this package and the guard sitecustomize."""
|
|
475
|
+
package = Path(__file__).resolve().parent
|
|
476
|
+
shutil.copytree(package, run_dir / package.name, ignore=shutil.ignore_patterns("__pycache__"))
|
|
477
|
+
(run_dir / "sitecustomize.py").write_bytes(SITECUSTOMIZE.encode("utf-8"))
|
|
478
|
+
for rel in set(HOME_DIRS.values()):
|
|
479
|
+
(run_dir / rel).mkdir(parents=True, exist_ok=True)
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def run_python(
|
|
483
|
+
python: str | os.PathLike[str],
|
|
484
|
+
args: list[str],
|
|
485
|
+
config: Config,
|
|
486
|
+
cache_dir: Path,
|
|
487
|
+
timeout: int = DEFAULT_TIMEOUT,
|
|
488
|
+
) -> SandboxResult:
|
|
489
|
+
"""Run ``python *args`` in the target root under the sandbox and return its JSON result.
|
|
490
|
+
|
|
491
|
+
``args`` starts with ``-m module`` or ``-c code``; both run through the guard bootstrap. The
|
|
492
|
+
run directory is a fresh ``tempfile.mkdtemp`` (``cache_dir`` is not used for it, so the child
|
|
493
|
+
never learns the cache path); it is removed afterwards.
|
|
494
|
+
"""
|
|
495
|
+
if args and args[0] not in ("-m", "-c"):
|
|
496
|
+
raise SandboxError(f"unsupported interpreter arguments: {' '.join(args[:2])}")
|
|
497
|
+
try:
|
|
498
|
+
exe = resolve_executable(python, untrusted=config.root)
|
|
499
|
+
except FileNotFoundError as exc:
|
|
500
|
+
raise SandboxError(f"cannot start {python}: {exc}") from exc
|
|
501
|
+
run_dir = Path(tempfile.mkdtemp(prefix="codeterrain-run-"))
|
|
502
|
+
result_path = run_dir / "result.json"
|
|
503
|
+
out_path, err_path = run_dir / "stdout.txt", run_dir / "stderr.txt"
|
|
504
|
+
try:
|
|
505
|
+
_stage(run_dir)
|
|
506
|
+
with open(out_path, "wb") as out, open(err_path, "wb") as err:
|
|
507
|
+
try:
|
|
508
|
+
proc = subprocess.Popen(
|
|
509
|
+
[exe, "-c", BOOTSTRAP, *args],
|
|
510
|
+
cwd=config.root,
|
|
511
|
+
env=build_env(config, run_dir, result_path),
|
|
512
|
+
stdin=subprocess.DEVNULL,
|
|
513
|
+
stdout=out,
|
|
514
|
+
stderr=err,
|
|
515
|
+
start_new_session=not WINDOWS,
|
|
516
|
+
)
|
|
517
|
+
except OSError as exc:
|
|
518
|
+
raise SandboxError(f"cannot start {python}: {exc}") from exc
|
|
519
|
+
job = _new_job(proc) if WINDOWS else None
|
|
520
|
+
try:
|
|
521
|
+
returncode = proc.wait(timeout=timeout)
|
|
522
|
+
except subprocess.TimeoutExpired as exc:
|
|
523
|
+
_kill_tree(proc, job)
|
|
524
|
+
raise SandboxError(
|
|
525
|
+
f"timed out after {timeout}s: {python} {' '.join(args)}"
|
|
526
|
+
) from exc
|
|
527
|
+
finally:
|
|
528
|
+
if job is not None:
|
|
529
|
+
job.close()
|
|
530
|
+
elif not WINDOWS:
|
|
531
|
+
_kill_group(proc.pid)
|
|
532
|
+
redact = redactor(config)
|
|
533
|
+
stdout, stderr = redact(_read_tail(out_path)), redact(_read_tail(err_path))
|
|
534
|
+
if returncode == GUARD_EXIT and GUARD_FAILED in stderr:
|
|
535
|
+
raise SandboxError(stderr.strip().splitlines()[-1])
|
|
536
|
+
return SandboxResult(returncode, stdout, stderr, _read_result(result_path))
|
|
537
|
+
finally:
|
|
538
|
+
_remove_run_dir(run_dir)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
VERSION_PROBE = (
|
|
542
|
+
"import json, os, sys\n"
|
|
543
|
+
"from importlib import metadata\n"
|
|
544
|
+
"def v(n):\n"
|
|
545
|
+
" try:\n"
|
|
546
|
+
" return metadata.version(n)\n"
|
|
547
|
+
" except metadata.PackageNotFoundError:\n"
|
|
548
|
+
" return None\n"
|
|
549
|
+
"sites = [p for p in sys.path\n"
|
|
550
|
+
" if os.path.basename(p) in ('site-packages', 'dist-packages') and os.path.isdir(p)]\n"
|
|
551
|
+
"dists = sorted({n for p in sites for n in os.listdir(p) if n.endswith('.dist-info')})\n"
|
|
552
|
+
"out = {'python': sys.version.split()[0], 'pytest': v('pytest'), 'fastapi': v('fastapi'),\n"
|
|
553
|
+
" 'prefix': sys.prefix, 'sites': sorted(sites), 'dists': dists}\n"
|
|
554
|
+
"open(os.environ['CODETERRAIN_RESULT'], 'w', encoding='utf-8').write(json.dumps(out))\n"
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def probe_versions(
|
|
559
|
+
python: str | os.PathLike[str], config: Config, cache_dir: Path
|
|
560
|
+
) -> dict[str, Any]:
|
|
561
|
+
"""Python, pytest and fastapi versions, the prefix, site dirs and installed distributions."""
|
|
562
|
+
result = run_python(python, ["-c", VERSION_PROBE], config, cache_dir, timeout=60)
|
|
563
|
+
if not isinstance(result.result, dict):
|
|
564
|
+
raise SandboxError(f"version probe failed: {result.stderr.strip()[-300:]}")
|
|
565
|
+
return result.result
|