engineering-process 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.
- engineering_process/__init__.py +3 -0
- engineering_process/_download_worker.py +73 -0
- engineering_process/_supervisor_posix.py +162 -0
- engineering_process/_supervisor_windows.py +148 -0
- engineering_process/_windows_job.py +419 -0
- engineering_process/bootstrap.py +217 -0
- engineering_process/bundles.py +81 -0
- engineering_process/cli.py +1012 -0
- engineering_process/contracts.py +1274 -0
- engineering_process/distribution.py +90 -0
- engineering_process/environment.py +753 -0
- engineering_process/helper_launch.py +37 -0
- engineering_process/lifecycle.py +1024 -0
- engineering_process/managed.py +89 -0
- engineering_process/markdown.py +78 -0
- engineering_process/publication.py +463 -0
- engineering_process/requirements-build.txt +1 -0
- engineering_process/requirements-dev.txt +10 -0
- engineering_process/requirements-runtime.txt +3 -0
- engineering_process/runner.py +191 -0
- engineering_process/runtime.py +55 -0
- engineering_process/skills.py +118 -0
- engineering_process/supervision.py +72 -0
- engineering_process/syncing.py +398 -0
- engineering_process/tooling.py +891 -0
- engineering_process-0.1.0.data/data/share/engineering-process/bundles.json +47 -0
- engineering_process-0.1.0.data/data/share/engineering-process/examples/change.json +26 -0
- engineering_process-0.1.0.data/data/share/engineering-process/examples/plan.json +28 -0
- engineering_process-0.1.0.data/data/share/engineering-process/examples/project.json +54 -0
- engineering_process-0.1.0.data/data/share/engineering-process/examples/review.json +31 -0
- engineering_process-0.1.0.data/data/share/engineering-process/schemas/change.schema.json +88 -0
- engineering_process-0.1.0.data/data/share/engineering-process/schemas/lifecycle.schema.json +122 -0
- engineering_process-0.1.0.data/data/share/engineering-process/schemas/plan.schema.json +105 -0
- engineering_process-0.1.0.data/data/share/engineering-process/schemas/process-lock.schema.json +39 -0
- engineering_process-0.1.0.data/data/share/engineering-process/schemas/project.schema.json +515 -0
- engineering_process-0.1.0.data/data/share/engineering-process/schemas/review.schema.json +140 -0
- engineering_process-0.1.0.data/data/share/engineering-process/schemas/reviewer-attestation.schema.json +30 -0
- engineering_process-0.1.0.data/data/share/engineering-process/schemas/verification.schema.json +120 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/assess-design/SKILL.md +39 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/build-frontend/SKILL.md +38 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/build-frontend-foundation/SKILL.md +36 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/change-api/SKILL.md +36 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/cross-repo-change/SKILL.md +37 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/define-change-contract/SKILL.md +40 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/design-module/SKILL.md +37 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/evolve-process/SKILL.md +37 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/finish-change/SKILL.md +34 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/govern-ui/SKILL.md +40 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/implement-change/SKILL.md +41 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/implement-module/SKILL.md +36 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/implement-use-case/SKILL.md +36 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/integrate-mcp/SKILL.md +37 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/maintain-docs/SKILL.md +35 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/plan-change/SKILL.md +37 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/publish-change/SKILL.md +38 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/review-change/SKILL.md +49 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/run-change/SKILL.md +51 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/run-change/references/execution.md +88 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/run-project-command/SKILL.md +55 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/specify-use-case/SKILL.md +37 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/verify-change/SKILL.md +38 -0
- engineering_process-0.1.0.data/data/share/engineering-process/templates/AGENTS.process.md +17 -0
- engineering_process-0.1.0.data/data/share/engineering-process/templates/PULL_REQUEST_TEMPLATE.md +27 -0
- engineering_process-0.1.0.dist-info/METADATA +387 -0
- engineering_process-0.1.0.dist-info/RECORD +69 -0
- engineering_process-0.1.0.dist-info/WHEEL +5 -0
- engineering_process-0.1.0.dist-info/entry_points.txt +2 -0
- engineering_process-0.1.0.dist-info/licenses/LICENSE +21 -0
- engineering_process-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,891 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
import posixpath
|
|
8
|
+
import stat
|
|
9
|
+
import struct
|
|
10
|
+
import subprocess
|
|
11
|
+
import tarfile
|
|
12
|
+
import tempfile
|
|
13
|
+
import time
|
|
14
|
+
from collections.abc import Callable, Iterable
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path, PurePosixPath
|
|
17
|
+
from typing import BinaryIO, Mapping
|
|
18
|
+
import urllib.request
|
|
19
|
+
from urllib.parse import urljoin, urlsplit
|
|
20
|
+
import zipfile
|
|
21
|
+
|
|
22
|
+
from .contracts import ContractError, ManagedTool, ManagedToolArtifact
|
|
23
|
+
from .helper_launch import isolated_helper_command
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
DOWNLOAD_READ_TIMEOUT_SECONDS = 30
|
|
27
|
+
MARKER_NAME = ".engineering-process-tool.json"
|
|
28
|
+
USER_AGENT = "engineering-process/0.1.0"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class ManagedCommandBinding:
|
|
33
|
+
application: Path
|
|
34
|
+
prefix_arguments: tuple[str, ...] = ()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _validated_https_target(base_url: str, target_url: str) -> str:
|
|
38
|
+
resolved = urljoin(base_url, target_url)
|
|
39
|
+
if "\\" in resolved or any(
|
|
40
|
+
ord(character) < 0x21 or ord(character) > 0x7e for character in resolved
|
|
41
|
+
):
|
|
42
|
+
raise ContractError(
|
|
43
|
+
"tool download redirect contains invalid URI characters"
|
|
44
|
+
)
|
|
45
|
+
try:
|
|
46
|
+
parsed = urlsplit(resolved)
|
|
47
|
+
parsed_port = parsed.port
|
|
48
|
+
except ValueError as error:
|
|
49
|
+
raise ContractError(f"invalid HTTPS redirect URL: {error}") from error
|
|
50
|
+
if (
|
|
51
|
+
parsed.scheme.lower() != "https"
|
|
52
|
+
or not parsed.hostname
|
|
53
|
+
or parsed.username is not None
|
|
54
|
+
or parsed.password is not None
|
|
55
|
+
or parsed.fragment
|
|
56
|
+
or parsed_port == 0
|
|
57
|
+
):
|
|
58
|
+
raise ContractError(f"tool download redirect is not a safe HTTPS URL: {resolved}")
|
|
59
|
+
return resolved
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class _HTTPSOnlyRedirectHandler(urllib.request.HTTPRedirectHandler):
|
|
63
|
+
def redirect_request(self, request, response, code, message, headers, new_url):
|
|
64
|
+
target = _validated_https_target(request.full_url, new_url)
|
|
65
|
+
return super().redirect_request(
|
|
66
|
+
request,
|
|
67
|
+
response,
|
|
68
|
+
code,
|
|
69
|
+
message,
|
|
70
|
+
headers,
|
|
71
|
+
target,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
_HTTPS_OPENER = urllib.request.build_opener(_HTTPSOnlyRedirectHandler())
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def platform_identifier(
|
|
79
|
+
*,
|
|
80
|
+
system: str | None = None,
|
|
81
|
+
machine: str | None = None,
|
|
82
|
+
libc: str | None = None,
|
|
83
|
+
) -> str:
|
|
84
|
+
system_name = (system or platform.system()).strip().lower()
|
|
85
|
+
machine_name = (machine or platform.machine()).strip().lower()
|
|
86
|
+
architectures = {
|
|
87
|
+
"aarch64": "arm64",
|
|
88
|
+
"amd64": "x64",
|
|
89
|
+
"arm64": "arm64",
|
|
90
|
+
"x86_64": "x64",
|
|
91
|
+
}
|
|
92
|
+
if machine_name not in architectures:
|
|
93
|
+
raise ContractError(f"unsupported tool architecture: {machine_name}")
|
|
94
|
+
architecture = architectures[machine_name]
|
|
95
|
+
if system_name == "linux":
|
|
96
|
+
libc_name = (libc if libc is not None else platform.libc_ver()[0]).lower()
|
|
97
|
+
if libc_name in {"glibc", "gnu libc"}:
|
|
98
|
+
libc_name = "glibc"
|
|
99
|
+
elif "musl" in libc_name:
|
|
100
|
+
libc_name = "musl"
|
|
101
|
+
else:
|
|
102
|
+
raise ContractError(
|
|
103
|
+
f"unsupported or undetected Linux C library: {libc_name or 'unknown'}"
|
|
104
|
+
)
|
|
105
|
+
return f"linux-{libc_name}-{architecture}"
|
|
106
|
+
if system_name == "darwin":
|
|
107
|
+
return f"macos-{architecture}"
|
|
108
|
+
if system_name == "windows":
|
|
109
|
+
return f"windows-{architecture}"
|
|
110
|
+
raise ContractError(f"unsupported tool operating system: {system_name}")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def managed_tools_root(
|
|
114
|
+
*,
|
|
115
|
+
environment: Mapping[str, str] | None = None,
|
|
116
|
+
user_home: Path | None = None,
|
|
117
|
+
platform_name: str | None = None,
|
|
118
|
+
) -> Path:
|
|
119
|
+
values = os.environ if environment is None else environment
|
|
120
|
+
home = Path.home() if user_home is None else user_home
|
|
121
|
+
current = (platform_name or platform.system()).lower()
|
|
122
|
+
if current == "windows":
|
|
123
|
+
local_app_data = values.get("LOCALAPPDATA")
|
|
124
|
+
base = Path(local_app_data) if local_app_data else home / "AppData" / "Local"
|
|
125
|
+
result = base / "EngineeringProcess" / "tools"
|
|
126
|
+
if not result.is_absolute():
|
|
127
|
+
raise ContractError("managed tool user-data directory must be absolute")
|
|
128
|
+
return result
|
|
129
|
+
if current == "darwin":
|
|
130
|
+
result = home / "Library" / "Application Support" / "EngineeringProcess" / "tools"
|
|
131
|
+
if not result.is_absolute():
|
|
132
|
+
raise ContractError("managed tool user-data directory must be absolute")
|
|
133
|
+
return result
|
|
134
|
+
xdg_data_home = values.get("XDG_DATA_HOME")
|
|
135
|
+
base = Path(xdg_data_home) if xdg_data_home else home / ".local" / "share"
|
|
136
|
+
result = base / "engineering-process" / "tools"
|
|
137
|
+
if not result.is_absolute():
|
|
138
|
+
raise ContractError("managed tool user-data directory must be absolute")
|
|
139
|
+
return result
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def selected_artifact(
|
|
143
|
+
tool: ManagedTool, *, current_platform: str | None = None
|
|
144
|
+
) -> ManagedToolArtifact:
|
|
145
|
+
selected = current_platform or platform_identifier()
|
|
146
|
+
artifact = tool.artifacts.get(selected)
|
|
147
|
+
if artifact is None:
|
|
148
|
+
available = ", ".join(sorted(tool.artifacts))
|
|
149
|
+
raise ContractError(
|
|
150
|
+
f"managed tool {tool.identifier} {tool.version} has no artifact for "
|
|
151
|
+
f"{selected}; declared platforms: {available}"
|
|
152
|
+
)
|
|
153
|
+
return artifact
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _artifact_key(artifact: ManagedToolArtifact) -> str:
|
|
157
|
+
return artifact.checksum.split(":", 1)[1][:24]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def install_root(
|
|
161
|
+
tool: ManagedTool,
|
|
162
|
+
artifact: ManagedToolArtifact,
|
|
163
|
+
*,
|
|
164
|
+
tools_root: Path | None = None,
|
|
165
|
+
) -> Path:
|
|
166
|
+
root = managed_tools_root() if tools_root is None else tools_root
|
|
167
|
+
return root / tool.identifier / tool.version / _artifact_key(artifact)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def managed_tool_preflight(
|
|
171
|
+
tool: ManagedTool,
|
|
172
|
+
*,
|
|
173
|
+
current_platform: str | None = None,
|
|
174
|
+
tools_root: Path | None = None,
|
|
175
|
+
) -> str | None:
|
|
176
|
+
artifact = selected_artifact(tool, current_platform=current_platform)
|
|
177
|
+
base = managed_tools_root() if tools_root is None else tools_root
|
|
178
|
+
root = install_root(tool, artifact, tools_root=base)
|
|
179
|
+
for directory in (base, base / tool.identifier, root.parent):
|
|
180
|
+
if directory.is_symlink():
|
|
181
|
+
return f"managed tool directory must not be a symlink: {directory}"
|
|
182
|
+
if directory.exists() and not directory.is_dir():
|
|
183
|
+
return f"managed tool directory is not a directory: {directory}"
|
|
184
|
+
if root.is_symlink():
|
|
185
|
+
return f"managed tool installation must not be a symlink: {root}"
|
|
186
|
+
if root.exists() and not installed_commands(
|
|
187
|
+
tool,
|
|
188
|
+
current_platform=artifact.platform,
|
|
189
|
+
tools_root=base,
|
|
190
|
+
):
|
|
191
|
+
return f"managed tool installation is invalid or unmanaged: {root}"
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _marker_document(tool: ManagedTool, artifact: ManagedToolArtifact) -> dict[str, object]:
|
|
196
|
+
has_script_binding = any(
|
|
197
|
+
command.script is not None for command in artifact.commands.values()
|
|
198
|
+
)
|
|
199
|
+
return {
|
|
200
|
+
"schemaVersion": 2 if has_script_binding else 1,
|
|
201
|
+
"tool": tool.identifier,
|
|
202
|
+
"version": tool.version,
|
|
203
|
+
"platform": artifact.platform,
|
|
204
|
+
"checksum": artifact.checksum,
|
|
205
|
+
"commands": {
|
|
206
|
+
name: (
|
|
207
|
+
{
|
|
208
|
+
"executable": command.executable,
|
|
209
|
+
"script": command.script,
|
|
210
|
+
}
|
|
211
|
+
if command.script is not None
|
|
212
|
+
else command.executable
|
|
213
|
+
)
|
|
214
|
+
for name, command in artifact.commands.items()
|
|
215
|
+
},
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _contained_command(root: Path, relative: str) -> Path:
|
|
220
|
+
candidate = root / relative
|
|
221
|
+
try:
|
|
222
|
+
resolved = candidate.resolve(strict=True)
|
|
223
|
+
resolved.relative_to(root.resolve(strict=True))
|
|
224
|
+
except (OSError, ValueError) as error:
|
|
225
|
+
raise ContractError(f"managed command escapes its install root: {relative}") from error
|
|
226
|
+
if not resolved.is_file():
|
|
227
|
+
raise ContractError(f"managed command is not a file: {relative}")
|
|
228
|
+
return candidate
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def installed_command_bindings(
|
|
232
|
+
tool: ManagedTool,
|
|
233
|
+
*,
|
|
234
|
+
current_platform: str | None = None,
|
|
235
|
+
tools_root: Path | None = None,
|
|
236
|
+
) -> dict[str, ManagedCommandBinding]:
|
|
237
|
+
artifact = selected_artifact(tool, current_platform=current_platform)
|
|
238
|
+
root = install_root(tool, artifact, tools_root=tools_root)
|
|
239
|
+
marker_path = root / MARKER_NAME
|
|
240
|
+
if not marker_path.is_file():
|
|
241
|
+
return {}
|
|
242
|
+
try:
|
|
243
|
+
marker = json.loads(marker_path.read_text(encoding="utf-8"))
|
|
244
|
+
except (OSError, UnicodeError, json.JSONDecodeError):
|
|
245
|
+
return {}
|
|
246
|
+
if marker != _marker_document(tool, artifact):
|
|
247
|
+
return {}
|
|
248
|
+
try:
|
|
249
|
+
bindings: dict[str, ManagedCommandBinding] = {}
|
|
250
|
+
for name, command in artifact.commands.items():
|
|
251
|
+
application = _contained_command(root, command.executable)
|
|
252
|
+
prefix_arguments = (
|
|
253
|
+
(str(_contained_command(root, command.script)),)
|
|
254
|
+
if command.script is not None
|
|
255
|
+
else ()
|
|
256
|
+
)
|
|
257
|
+
bindings[name] = ManagedCommandBinding(
|
|
258
|
+
application=application,
|
|
259
|
+
prefix_arguments=prefix_arguments,
|
|
260
|
+
)
|
|
261
|
+
return bindings
|
|
262
|
+
except ContractError:
|
|
263
|
+
return {}
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def installed_commands(
|
|
267
|
+
tool: ManagedTool,
|
|
268
|
+
*,
|
|
269
|
+
current_platform: str | None = None,
|
|
270
|
+
tools_root: Path | None = None,
|
|
271
|
+
) -> dict[str, Path]:
|
|
272
|
+
return {
|
|
273
|
+
name: binding.application
|
|
274
|
+
for name, binding in installed_command_bindings(
|
|
275
|
+
tool,
|
|
276
|
+
current_platform=current_platform,
|
|
277
|
+
tools_root=tools_root,
|
|
278
|
+
).items()
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def managed_command_bindings(
|
|
283
|
+
tools: Iterable[ManagedTool],
|
|
284
|
+
*,
|
|
285
|
+
current_platform: str | None = None,
|
|
286
|
+
tools_root: Path | None = None,
|
|
287
|
+
) -> dict[str, ManagedCommandBinding]:
|
|
288
|
+
result: dict[str, ManagedCommandBinding] = {}
|
|
289
|
+
for tool in tools:
|
|
290
|
+
for name, binding in installed_command_bindings(
|
|
291
|
+
tool,
|
|
292
|
+
current_platform=current_platform,
|
|
293
|
+
tools_root=tools_root,
|
|
294
|
+
).items():
|
|
295
|
+
if name in result and result[name] != binding:
|
|
296
|
+
raise ContractError(f"managed tools provide duplicate command: {name}")
|
|
297
|
+
result[name] = binding
|
|
298
|
+
return result
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def managed_path_entries(
|
|
302
|
+
tools: Iterable[ManagedTool],
|
|
303
|
+
*,
|
|
304
|
+
current_platform: str | None = None,
|
|
305
|
+
tools_root: Path | None = None,
|
|
306
|
+
) -> tuple[Path, ...]:
|
|
307
|
+
entries: list[Path] = []
|
|
308
|
+
for tool in tools:
|
|
309
|
+
commands = installed_command_bindings(
|
|
310
|
+
tool,
|
|
311
|
+
current_platform=current_platform,
|
|
312
|
+
tools_root=tools_root,
|
|
313
|
+
)
|
|
314
|
+
for command in commands.values():
|
|
315
|
+
parent = command.application.parent
|
|
316
|
+
if parent not in entries:
|
|
317
|
+
entries.append(parent)
|
|
318
|
+
return tuple(entries)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _remaining(deadline: float) -> float:
|
|
322
|
+
remaining = deadline - time.monotonic()
|
|
323
|
+
if remaining <= 0:
|
|
324
|
+
raise ContractError("managed tool installation exceeded its timeout")
|
|
325
|
+
return remaining
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _set_response_read_timeout(response: object, timeout: float) -> None:
|
|
329
|
+
"""Apply the remaining wall-clock budget to the active HTTPS socket."""
|
|
330
|
+
|
|
331
|
+
stream = getattr(response, "fp", None)
|
|
332
|
+
raw = getattr(stream, "raw", None)
|
|
333
|
+
active_socket = getattr(raw, "_sock", None)
|
|
334
|
+
set_timeout = getattr(active_socket, "settimeout", None)
|
|
335
|
+
if not callable(set_timeout):
|
|
336
|
+
raise ContractError("cannot enforce the managed download read deadline")
|
|
337
|
+
try:
|
|
338
|
+
set_timeout(max(0.001, timeout))
|
|
339
|
+
except OSError as error:
|
|
340
|
+
raise ContractError(f"cannot update the managed download timeout: {error}") from error
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _download_artifact_direct(
|
|
344
|
+
artifact: ManagedToolArtifact,
|
|
345
|
+
destination: Path,
|
|
346
|
+
*,
|
|
347
|
+
deadline: float,
|
|
348
|
+
) -> None:
|
|
349
|
+
request = urllib.request.Request(artifact.url, headers={"User-Agent": USER_AGENT})
|
|
350
|
+
with _HTTPS_OPENER.open(
|
|
351
|
+
request,
|
|
352
|
+
timeout=min(DOWNLOAD_READ_TIMEOUT_SECONDS, _remaining(deadline)),
|
|
353
|
+
) as response:
|
|
354
|
+
_validated_https_target(artifact.url, response.geturl())
|
|
355
|
+
content_length = response.headers.get("Content-Length")
|
|
356
|
+
if content_length is not None:
|
|
357
|
+
try:
|
|
358
|
+
declared_size = int(content_length)
|
|
359
|
+
except ValueError as error:
|
|
360
|
+
raise ContractError("tool response has an invalid Content-Length") from error
|
|
361
|
+
if declared_size > artifact.max_download_bytes:
|
|
362
|
+
raise ContractError(
|
|
363
|
+
f"tool artifact exceeds maxDownloadBytes: {declared_size} > "
|
|
364
|
+
f"{artifact.max_download_bytes}"
|
|
365
|
+
)
|
|
366
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
367
|
+
total = 0
|
|
368
|
+
with destination.open("xb") as output:
|
|
369
|
+
while True:
|
|
370
|
+
_set_response_read_timeout(
|
|
371
|
+
response,
|
|
372
|
+
min(DOWNLOAD_READ_TIMEOUT_SECONDS, _remaining(deadline)),
|
|
373
|
+
)
|
|
374
|
+
block = response.read(min(1024 * 1024, artifact.max_download_bytes - total + 1))
|
|
375
|
+
if not block:
|
|
376
|
+
break
|
|
377
|
+
total += len(block)
|
|
378
|
+
if total > artifact.max_download_bytes:
|
|
379
|
+
raise ContractError(
|
|
380
|
+
f"tool artifact exceeds maxDownloadBytes: {artifact.max_download_bytes}"
|
|
381
|
+
)
|
|
382
|
+
output.write(block)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def download_artifact(
|
|
386
|
+
artifact: ManagedToolArtifact,
|
|
387
|
+
destination: Path,
|
|
388
|
+
*,
|
|
389
|
+
deadline: float,
|
|
390
|
+
) -> None:
|
|
391
|
+
"""Download in a force-terminable worker bounded by the action deadline."""
|
|
392
|
+
|
|
393
|
+
remaining = _remaining(deadline)
|
|
394
|
+
payload = {
|
|
395
|
+
"destination": str(destination.resolve()),
|
|
396
|
+
"maxDownloadBytes": artifact.max_download_bytes,
|
|
397
|
+
"timeoutSeconds": remaining,
|
|
398
|
+
"url": artifact.url,
|
|
399
|
+
}
|
|
400
|
+
try:
|
|
401
|
+
result = subprocess.run(
|
|
402
|
+
isolated_helper_command("engineering_process._download_worker"),
|
|
403
|
+
check=False,
|
|
404
|
+
input=json.dumps(payload, ensure_ascii=True, separators=(",", ":")).encode(
|
|
405
|
+
"ascii"
|
|
406
|
+
),
|
|
407
|
+
stdout=subprocess.DEVNULL,
|
|
408
|
+
stderr=subprocess.PIPE,
|
|
409
|
+
timeout=remaining,
|
|
410
|
+
)
|
|
411
|
+
except subprocess.TimeoutExpired as error:
|
|
412
|
+
destination.unlink(missing_ok=True)
|
|
413
|
+
raise ContractError("managed tool installation exceeded its timeout") from error
|
|
414
|
+
if result.returncode != 0:
|
|
415
|
+
destination.unlink(missing_ok=True)
|
|
416
|
+
detail = result.stderr[:4096].decode("utf-8", errors="replace").strip()
|
|
417
|
+
raise ContractError(
|
|
418
|
+
"managed tool download failed" + (f": {detail}" if detail else "")
|
|
419
|
+
)
|
|
420
|
+
if not destination.is_file():
|
|
421
|
+
raise ContractError("managed tool download worker produced no artifact")
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def verify_checksum(path: Path, checksum: str, *, deadline: float) -> None:
|
|
425
|
+
algorithm, expected = checksum.split(":", 1)
|
|
426
|
+
digest = hashlib.new(algorithm)
|
|
427
|
+
with path.open("rb") as source:
|
|
428
|
+
while block := source.read(1024 * 1024):
|
|
429
|
+
_remaining(deadline)
|
|
430
|
+
digest.update(block)
|
|
431
|
+
actual = digest.hexdigest().lower()
|
|
432
|
+
if actual != expected:
|
|
433
|
+
raise ContractError(
|
|
434
|
+
f"checksum mismatch for {path.name}: expected {expected}, got {actual}"
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _safe_member_path(name: str, target: Path) -> Path:
|
|
439
|
+
portable = PurePosixPath(name.replace("\\", "/"))
|
|
440
|
+
if (
|
|
441
|
+
portable.is_absolute()
|
|
442
|
+
or ".." in portable.parts
|
|
443
|
+
or any(":" in part for part in portable.parts)
|
|
444
|
+
):
|
|
445
|
+
raise ContractError(f"unsafe archive member: {name}")
|
|
446
|
+
candidate = (target / Path(*portable.parts)).resolve()
|
|
447
|
+
resolved_target = target.resolve()
|
|
448
|
+
if candidate != resolved_target and resolved_target not in candidate.parents:
|
|
449
|
+
raise ContractError(f"unsafe archive member: {name}")
|
|
450
|
+
return candidate
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _safe_tar_link(member: tarfile.TarInfo, target: Path) -> None:
|
|
454
|
+
link = PurePosixPath(member.linkname.replace("\\", "/"))
|
|
455
|
+
if link.is_absolute():
|
|
456
|
+
raise ContractError(
|
|
457
|
+
f"unsafe archive link: {member.name} -> {member.linkname}"
|
|
458
|
+
)
|
|
459
|
+
combined = link if member.islnk() else PurePosixPath(member.name).parent / link
|
|
460
|
+
normalized = posixpath.normpath(str(combined))
|
|
461
|
+
if normalized == ".." or normalized.startswith("../"):
|
|
462
|
+
raise ContractError(
|
|
463
|
+
f"unsafe archive link: {member.name} -> {member.linkname}"
|
|
464
|
+
)
|
|
465
|
+
_safe_member_path(normalized, target)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _validate_archive_limits(
|
|
469
|
+
*,
|
|
470
|
+
count: int,
|
|
471
|
+
size: int,
|
|
472
|
+
artifact: ManagedToolArtifact,
|
|
473
|
+
) -> None:
|
|
474
|
+
if count > artifact.max_files:
|
|
475
|
+
raise ContractError(
|
|
476
|
+
f"tool archive exceeds maxFiles: {count} > {artifact.max_files}"
|
|
477
|
+
)
|
|
478
|
+
if size > artifact.max_extracted_bytes:
|
|
479
|
+
raise ContractError(
|
|
480
|
+
"tool archive exceeds maxExtractedBytes: "
|
|
481
|
+
f"{size} > {artifact.max_extracted_bytes}"
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _copy_stream(
|
|
486
|
+
source: BinaryIO,
|
|
487
|
+
destination: Path,
|
|
488
|
+
*,
|
|
489
|
+
deadline: float,
|
|
490
|
+
max_bytes: int,
|
|
491
|
+
expected_bytes: int | None = None,
|
|
492
|
+
) -> int:
|
|
493
|
+
written = 0
|
|
494
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
495
|
+
with destination.open("xb") as output:
|
|
496
|
+
while True:
|
|
497
|
+
_remaining(deadline)
|
|
498
|
+
block = source.read(min(1024 * 1024, max_bytes - written + 1))
|
|
499
|
+
if not block:
|
|
500
|
+
break
|
|
501
|
+
written += len(block)
|
|
502
|
+
if written > max_bytes:
|
|
503
|
+
raise ContractError(
|
|
504
|
+
f"extracted artifact exceeds its {max_bytes}-byte limit"
|
|
505
|
+
)
|
|
506
|
+
output.write(block)
|
|
507
|
+
if expected_bytes is not None and written != expected_bytes:
|
|
508
|
+
raise ContractError(
|
|
509
|
+
f"archive member size mismatch: expected {expected_bytes}, got {written}"
|
|
510
|
+
)
|
|
511
|
+
return written
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _apply_mode(path: Path, mode: int) -> None:
|
|
515
|
+
if os.name != "nt" and mode:
|
|
516
|
+
path.chmod(mode & 0o777)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
_ZIP_EOCD = struct.Struct("<4s4H2LH")
|
|
520
|
+
_ZIP64_LOCATOR = struct.Struct("<4sLQL")
|
|
521
|
+
_ZIP64_EOCD = struct.Struct("<4sQ2H2L4Q")
|
|
522
|
+
_ZIP_CENTRAL_HEADER = struct.Struct("<4s6H3L5H2L")
|
|
523
|
+
_ZIP_EOCD_SIGNATURE = b"PK\x05\x06"
|
|
524
|
+
_ZIP64_LOCATOR_SIGNATURE = b"PK\x06\x07"
|
|
525
|
+
_ZIP64_EOCD_SIGNATURE = b"PK\x06\x06"
|
|
526
|
+
_ZIP_CENTRAL_SIGNATURE = b"PK\x01\x02"
|
|
527
|
+
_ZIP_MAX_COMMENT = 65_535
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _read_exact(source: BinaryIO, size: int, label: str) -> bytes:
|
|
531
|
+
content = source.read(size)
|
|
532
|
+
if len(content) != size:
|
|
533
|
+
raise ContractError(f"invalid zip artifact: truncated {label}")
|
|
534
|
+
return content
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _zip_directory_bounds(archive: Path) -> tuple[int, int, int]:
|
|
538
|
+
"""Return declared entry count and actual central-directory byte bounds."""
|
|
539
|
+
|
|
540
|
+
archive_size = archive.stat().st_size
|
|
541
|
+
tail_size = min(archive_size, _ZIP_EOCD.size + _ZIP_MAX_COMMENT)
|
|
542
|
+
with archive.open("rb") as source:
|
|
543
|
+
source.seek(archive_size - tail_size)
|
|
544
|
+
tail = _read_exact(source, tail_size, "end record")
|
|
545
|
+
relative_eocd = tail.rfind(_ZIP_EOCD_SIGNATURE)
|
|
546
|
+
if relative_eocd < 0 or relative_eocd + _ZIP_EOCD.size > len(tail):
|
|
547
|
+
raise ContractError("invalid zip artifact: end record is missing")
|
|
548
|
+
eocd = _ZIP_EOCD.unpack_from(tail, relative_eocd)
|
|
549
|
+
comment_length = eocd[-1]
|
|
550
|
+
if relative_eocd + _ZIP_EOCD.size + comment_length != len(tail):
|
|
551
|
+
raise ContractError("invalid zip artifact: ambiguous end record")
|
|
552
|
+
eocd_offset = archive_size - tail_size + relative_eocd
|
|
553
|
+
disk_number, directory_disk, disk_entries, total_entries = eocd[1:5]
|
|
554
|
+
directory_size = eocd[5]
|
|
555
|
+
directory_offset = eocd[6]
|
|
556
|
+
if disk_number != 0 or directory_disk != 0 or disk_entries != total_entries:
|
|
557
|
+
raise ContractError("invalid zip artifact: multi-disk archives are unsupported")
|
|
558
|
+
|
|
559
|
+
directory_end = eocd_offset
|
|
560
|
+
locator_offset = eocd_offset - _ZIP64_LOCATOR.size
|
|
561
|
+
if locator_offset >= 0:
|
|
562
|
+
source.seek(locator_offset)
|
|
563
|
+
locator_content = _read_exact(source, _ZIP64_LOCATOR.size, "ZIP64 locator")
|
|
564
|
+
if locator_content.startswith(_ZIP64_LOCATOR_SIGNATURE):
|
|
565
|
+
locator = _ZIP64_LOCATOR.unpack(locator_content)
|
|
566
|
+
if locator[1] != 0 or locator[3] != 1:
|
|
567
|
+
raise ContractError(
|
|
568
|
+
"invalid zip artifact: multi-disk ZIP64 archives are unsupported"
|
|
569
|
+
)
|
|
570
|
+
zip64_offset = locator[2]
|
|
571
|
+
source.seek(zip64_offset)
|
|
572
|
+
zip64_content = _read_exact(source, _ZIP64_EOCD.size, "ZIP64 end record")
|
|
573
|
+
zip64 = _ZIP64_EOCD.unpack(zip64_content)
|
|
574
|
+
if zip64[0] != _ZIP64_EOCD_SIGNATURE or zip64[1] < 44:
|
|
575
|
+
raise ContractError("invalid zip artifact: malformed ZIP64 end record")
|
|
576
|
+
if zip64_offset + 12 + zip64[1] != locator_offset:
|
|
577
|
+
raise ContractError("invalid zip artifact: malformed ZIP64 layout")
|
|
578
|
+
if zip64[4] != 0 or zip64[5] != 0 or zip64[6] != zip64[7]:
|
|
579
|
+
raise ContractError(
|
|
580
|
+
"invalid zip artifact: multi-disk ZIP64 archives are unsupported"
|
|
581
|
+
)
|
|
582
|
+
total_entries = zip64[7]
|
|
583
|
+
directory_size = zip64[8]
|
|
584
|
+
directory_offset = zip64[9]
|
|
585
|
+
directory_end = zip64_offset
|
|
586
|
+
elif (
|
|
587
|
+
total_entries == 0xFFFF
|
|
588
|
+
or directory_size == 0xFFFFFFFF
|
|
589
|
+
or directory_offset == 0xFFFFFFFF
|
|
590
|
+
):
|
|
591
|
+
raise ContractError("invalid zip artifact: ZIP64 locator is missing")
|
|
592
|
+
|
|
593
|
+
directory_start = directory_end - directory_size
|
|
594
|
+
if directory_start < 0 or directory_offset > directory_start:
|
|
595
|
+
raise ContractError("invalid zip artifact: central directory is out of bounds")
|
|
596
|
+
return int(total_entries), int(directory_start), int(directory_end)
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _preflight_zip_directory(
|
|
600
|
+
archive: Path,
|
|
601
|
+
artifact: ManagedToolArtifact,
|
|
602
|
+
*,
|
|
603
|
+
deadline: float,
|
|
604
|
+
) -> int:
|
|
605
|
+
declared_entries, directory_start, directory_end = _zip_directory_bounds(archive)
|
|
606
|
+
_validate_archive_limits(count=declared_entries, size=0, artifact=artifact)
|
|
607
|
+
parsed_entries = 0
|
|
608
|
+
with archive.open("rb") as source:
|
|
609
|
+
source.seek(directory_start)
|
|
610
|
+
while source.tell() < directory_end:
|
|
611
|
+
_remaining(deadline)
|
|
612
|
+
header = _ZIP_CENTRAL_HEADER.unpack(
|
|
613
|
+
_read_exact(source, _ZIP_CENTRAL_HEADER.size, "central directory header")
|
|
614
|
+
)
|
|
615
|
+
if header[0] != _ZIP_CENTRAL_SIGNATURE:
|
|
616
|
+
raise ContractError(
|
|
617
|
+
"invalid zip artifact: malformed central directory entry"
|
|
618
|
+
)
|
|
619
|
+
variable_size = header[10] + header[11] + header[12]
|
|
620
|
+
if source.tell() + variable_size > directory_end:
|
|
621
|
+
raise ContractError(
|
|
622
|
+
"invalid zip artifact: central directory entry is out of bounds"
|
|
623
|
+
)
|
|
624
|
+
source.seek(variable_size, os.SEEK_CUR)
|
|
625
|
+
parsed_entries += 1
|
|
626
|
+
_validate_archive_limits(count=parsed_entries, size=0, artifact=artifact)
|
|
627
|
+
if source.tell() != directory_end or parsed_entries != declared_entries:
|
|
628
|
+
raise ContractError("invalid zip artifact: central directory count mismatch")
|
|
629
|
+
return parsed_entries
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _extract_zip(
|
|
633
|
+
archive: Path,
|
|
634
|
+
target: Path,
|
|
635
|
+
artifact: ManagedToolArtifact,
|
|
636
|
+
*,
|
|
637
|
+
deadline: float,
|
|
638
|
+
) -> None:
|
|
639
|
+
expected_members = _preflight_zip_directory(
|
|
640
|
+
archive, artifact, deadline=deadline
|
|
641
|
+
)
|
|
642
|
+
with zipfile.ZipFile(archive) as handle:
|
|
643
|
+
directory_modes: list[tuple[Path, int]] = []
|
|
644
|
+
members = handle.infolist()
|
|
645
|
+
if len(members) != expected_members:
|
|
646
|
+
raise ContractError("invalid zip artifact: parsed member count changed")
|
|
647
|
+
normalized_names = [
|
|
648
|
+
member.filename.replace("\\", "/").casefold() for member in members
|
|
649
|
+
]
|
|
650
|
+
if len(normalized_names) != len(set(normalized_names)):
|
|
651
|
+
raise ContractError("tool archive contains duplicate member paths")
|
|
652
|
+
_validate_archive_limits(
|
|
653
|
+
count=len(members),
|
|
654
|
+
size=sum(member.file_size for member in members),
|
|
655
|
+
artifact=artifact,
|
|
656
|
+
)
|
|
657
|
+
for member in members:
|
|
658
|
+
_remaining(deadline)
|
|
659
|
+
destination = _safe_member_path(member.filename, target)
|
|
660
|
+
member_mode = member.external_attr >> 16
|
|
661
|
+
if stat.S_ISLNK(member_mode):
|
|
662
|
+
raise ContractError(f"zip symlinks are unsupported: {member.filename}")
|
|
663
|
+
if member.is_dir():
|
|
664
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
665
|
+
directory_modes.append((destination, member_mode))
|
|
666
|
+
continue
|
|
667
|
+
with handle.open(member, "r") as source:
|
|
668
|
+
_copy_stream(
|
|
669
|
+
source,
|
|
670
|
+
destination,
|
|
671
|
+
deadline=deadline,
|
|
672
|
+
max_bytes=member.file_size,
|
|
673
|
+
expected_bytes=member.file_size,
|
|
674
|
+
)
|
|
675
|
+
_apply_mode(destination, member_mode)
|
|
676
|
+
for directory, mode in reversed(directory_modes):
|
|
677
|
+
_remaining(deadline)
|
|
678
|
+
_apply_mode(directory, mode)
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def _extract_tar(
|
|
682
|
+
archive: Path,
|
|
683
|
+
target: Path,
|
|
684
|
+
artifact: ManagedToolArtifact,
|
|
685
|
+
*,
|
|
686
|
+
deadline: float,
|
|
687
|
+
) -> None:
|
|
688
|
+
names: set[str] = set()
|
|
689
|
+
count = 0
|
|
690
|
+
extracted_bytes = 0
|
|
691
|
+
directory_modes: list[tuple[Path, int]] = []
|
|
692
|
+
pending_hard_links: list[tuple[Path, Path]] = []
|
|
693
|
+
with tarfile.open(archive, "r:gz") as handle:
|
|
694
|
+
for member in handle:
|
|
695
|
+
_remaining(deadline)
|
|
696
|
+
normalized_name = member.name.replace("\\", "/").casefold()
|
|
697
|
+
if normalized_name in names:
|
|
698
|
+
raise ContractError("tool archive contains duplicate member paths")
|
|
699
|
+
names.add(normalized_name)
|
|
700
|
+
count += 1
|
|
701
|
+
if member.isfile():
|
|
702
|
+
extracted_bytes += member.size
|
|
703
|
+
_validate_archive_limits(
|
|
704
|
+
count=count,
|
|
705
|
+
size=extracted_bytes,
|
|
706
|
+
artifact=artifact,
|
|
707
|
+
)
|
|
708
|
+
destination = _safe_member_path(member.name, target)
|
|
709
|
+
if member.isdir():
|
|
710
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
711
|
+
directory_modes.append((destination, member.mode))
|
|
712
|
+
elif member.isfile():
|
|
713
|
+
source = handle.extractfile(member)
|
|
714
|
+
if source is None:
|
|
715
|
+
raise ContractError(f"cannot read archive member: {member.name}")
|
|
716
|
+
with source:
|
|
717
|
+
_copy_stream(
|
|
718
|
+
source,
|
|
719
|
+
destination,
|
|
720
|
+
deadline=deadline,
|
|
721
|
+
max_bytes=member.size,
|
|
722
|
+
expected_bytes=member.size,
|
|
723
|
+
)
|
|
724
|
+
_apply_mode(destination, member.mode)
|
|
725
|
+
elif member.issym():
|
|
726
|
+
_safe_tar_link(member, target)
|
|
727
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
728
|
+
os.symlink(member.linkname, destination)
|
|
729
|
+
elif member.islnk():
|
|
730
|
+
_safe_tar_link(member, target)
|
|
731
|
+
source = _safe_member_path(member.linkname, target)
|
|
732
|
+
pending_hard_links.append((source, destination))
|
|
733
|
+
else:
|
|
734
|
+
raise ContractError(f"unsupported archive member type: {member.name}")
|
|
735
|
+
for source, destination in pending_hard_links:
|
|
736
|
+
_remaining(deadline)
|
|
737
|
+
if not source.is_file():
|
|
738
|
+
raise ContractError(f"archive hard-link target is missing: {source.name}")
|
|
739
|
+
_safe_member_path(str(destination.relative_to(target)), target)
|
|
740
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
741
|
+
os.link(source, destination)
|
|
742
|
+
for directory, mode in reversed(directory_modes):
|
|
743
|
+
_remaining(deadline)
|
|
744
|
+
_apply_mode(directory, mode)
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def extract_artifact(
|
|
748
|
+
archive: Path,
|
|
749
|
+
target: Path,
|
|
750
|
+
artifact: ManagedToolArtifact,
|
|
751
|
+
*,
|
|
752
|
+
deadline: float,
|
|
753
|
+
) -> None:
|
|
754
|
+
target.mkdir(parents=True, exist_ok=False)
|
|
755
|
+
if artifact.archive_format == "zip":
|
|
756
|
+
try:
|
|
757
|
+
_extract_zip(archive, target, artifact, deadline=deadline)
|
|
758
|
+
except (zipfile.BadZipFile, zipfile.LargeZipFile, RuntimeError) as error:
|
|
759
|
+
raise ContractError(f"invalid zip artifact: {error}") from error
|
|
760
|
+
return
|
|
761
|
+
if artifact.archive_format == "tar.gz":
|
|
762
|
+
try:
|
|
763
|
+
_extract_tar(archive, target, artifact, deadline=deadline)
|
|
764
|
+
except (tarfile.TarError, EOFError) as error:
|
|
765
|
+
raise ContractError(f"invalid tar artifact: {error}") from error
|
|
766
|
+
return
|
|
767
|
+
raise ContractError(f"unsupported archive format: {artifact.archive_format}")
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def _payload_root(payload: Path, strip_components: int) -> Path:
|
|
771
|
+
if strip_components == 0:
|
|
772
|
+
return payload
|
|
773
|
+
children = [child for child in payload.iterdir()]
|
|
774
|
+
if (
|
|
775
|
+
len(children) != 1
|
|
776
|
+
or children[0].is_symlink()
|
|
777
|
+
or not children[0].is_dir()
|
|
778
|
+
):
|
|
779
|
+
raise ContractError(
|
|
780
|
+
"stripComponents 1 requires exactly one top-level directory"
|
|
781
|
+
)
|
|
782
|
+
return children[0]
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
Downloader = Callable[[ManagedToolArtifact, Path, float], None]
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def _default_downloader(
|
|
789
|
+
artifact: ManagedToolArtifact, destination: Path, deadline: float
|
|
790
|
+
) -> None:
|
|
791
|
+
download_artifact(artifact, destination, deadline=deadline)
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def install_managed_tool(
|
|
795
|
+
tool: ManagedTool,
|
|
796
|
+
*,
|
|
797
|
+
timeout_seconds: int,
|
|
798
|
+
current_platform: str | None = None,
|
|
799
|
+
tools_root: Path | None = None,
|
|
800
|
+
downloader: Downloader = _default_downloader,
|
|
801
|
+
) -> dict[str, Path]:
|
|
802
|
+
artifact = selected_artifact(tool, current_platform=current_platform)
|
|
803
|
+
root = install_root(tool, artifact, tools_root=tools_root)
|
|
804
|
+
preflight_issue = managed_tool_preflight(
|
|
805
|
+
tool,
|
|
806
|
+
current_platform=artifact.platform,
|
|
807
|
+
tools_root=tools_root,
|
|
808
|
+
)
|
|
809
|
+
if preflight_issue is not None:
|
|
810
|
+
raise ContractError(preflight_issue)
|
|
811
|
+
existing = installed_commands(
|
|
812
|
+
tool,
|
|
813
|
+
current_platform=artifact.platform,
|
|
814
|
+
tools_root=tools_root,
|
|
815
|
+
)
|
|
816
|
+
if existing:
|
|
817
|
+
return existing
|
|
818
|
+
deadline = time.monotonic() + timeout_seconds
|
|
819
|
+
parent = root.parent
|
|
820
|
+
parent.mkdir(parents=True, exist_ok=True)
|
|
821
|
+
with tempfile.TemporaryDirectory(prefix=".install-", dir=parent) as directory:
|
|
822
|
+
temporary = Path(directory)
|
|
823
|
+
archive = temporary / "artifact"
|
|
824
|
+
downloader(artifact, archive, deadline)
|
|
825
|
+
if not archive.is_file():
|
|
826
|
+
raise ContractError("managed tool downloader did not create an artifact file")
|
|
827
|
+
if archive.stat().st_size > artifact.max_download_bytes:
|
|
828
|
+
raise ContractError(
|
|
829
|
+
f"tool artifact exceeds maxDownloadBytes: {artifact.max_download_bytes}"
|
|
830
|
+
)
|
|
831
|
+
verify_checksum(archive, artifact.checksum, deadline=deadline)
|
|
832
|
+
|
|
833
|
+
staged = temporary / "staged"
|
|
834
|
+
if artifact.archive_format == "file":
|
|
835
|
+
if archive.stat().st_size > artifact.max_extracted_bytes:
|
|
836
|
+
raise ContractError(
|
|
837
|
+
"file artifact exceeds maxExtractedBytes: "
|
|
838
|
+
f"{archive.stat().st_size} > {artifact.max_extracted_bytes}"
|
|
839
|
+
)
|
|
840
|
+
staged.mkdir()
|
|
841
|
+
relative = next(iter(artifact.commands.values())).executable
|
|
842
|
+
target = staged / relative
|
|
843
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
844
|
+
with archive.open("rb") as source:
|
|
845
|
+
_copy_stream(
|
|
846
|
+
source,
|
|
847
|
+
target,
|
|
848
|
+
deadline=deadline,
|
|
849
|
+
max_bytes=artifact.max_extracted_bytes,
|
|
850
|
+
expected_bytes=archive.stat().st_size,
|
|
851
|
+
)
|
|
852
|
+
else:
|
|
853
|
+
payload = temporary / "payload"
|
|
854
|
+
extract_artifact(archive, payload, artifact, deadline=deadline)
|
|
855
|
+
source = _payload_root(payload, artifact.strip_components)
|
|
856
|
+
source.rename(staged)
|
|
857
|
+
|
|
858
|
+
for managed_command in artifact.commands.values():
|
|
859
|
+
command = _contained_command(staged, managed_command.executable)
|
|
860
|
+
if os.name != "nt":
|
|
861
|
+
command.chmod(command.stat().st_mode | 0o111)
|
|
862
|
+
if managed_command.script is not None:
|
|
863
|
+
_contained_command(staged, managed_command.script)
|
|
864
|
+
marker = _marker_document(tool, artifact)
|
|
865
|
+
(staged / MARKER_NAME).write_text(
|
|
866
|
+
json.dumps(marker, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
|
867
|
+
encoding="utf-8",
|
|
868
|
+
)
|
|
869
|
+
_remaining(deadline)
|
|
870
|
+
try:
|
|
871
|
+
staged.rename(root)
|
|
872
|
+
except FileExistsError:
|
|
873
|
+
concurrent = installed_commands(
|
|
874
|
+
tool,
|
|
875
|
+
current_platform=artifact.platform,
|
|
876
|
+
tools_root=tools_root,
|
|
877
|
+
)
|
|
878
|
+
if concurrent:
|
|
879
|
+
return concurrent
|
|
880
|
+
raise ContractError(
|
|
881
|
+
f"concurrent managed tool installation produced an invalid target: {root}"
|
|
882
|
+
)
|
|
883
|
+
|
|
884
|
+
installed = installed_commands(
|
|
885
|
+
tool,
|
|
886
|
+
current_platform=artifact.platform,
|
|
887
|
+
tools_root=tools_root,
|
|
888
|
+
)
|
|
889
|
+
if not installed:
|
|
890
|
+
raise ContractError("managed tool installation did not pass ownership validation")
|
|
891
|
+
return installed
|