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.
Files changed (69) hide show
  1. engineering_process/__init__.py +3 -0
  2. engineering_process/_download_worker.py +73 -0
  3. engineering_process/_supervisor_posix.py +162 -0
  4. engineering_process/_supervisor_windows.py +148 -0
  5. engineering_process/_windows_job.py +419 -0
  6. engineering_process/bootstrap.py +217 -0
  7. engineering_process/bundles.py +81 -0
  8. engineering_process/cli.py +1012 -0
  9. engineering_process/contracts.py +1274 -0
  10. engineering_process/distribution.py +90 -0
  11. engineering_process/environment.py +753 -0
  12. engineering_process/helper_launch.py +37 -0
  13. engineering_process/lifecycle.py +1024 -0
  14. engineering_process/managed.py +89 -0
  15. engineering_process/markdown.py +78 -0
  16. engineering_process/publication.py +463 -0
  17. engineering_process/requirements-build.txt +1 -0
  18. engineering_process/requirements-dev.txt +10 -0
  19. engineering_process/requirements-runtime.txt +3 -0
  20. engineering_process/runner.py +191 -0
  21. engineering_process/runtime.py +55 -0
  22. engineering_process/skills.py +118 -0
  23. engineering_process/supervision.py +72 -0
  24. engineering_process/syncing.py +398 -0
  25. engineering_process/tooling.py +891 -0
  26. engineering_process-0.1.0.data/data/share/engineering-process/bundles.json +47 -0
  27. engineering_process-0.1.0.data/data/share/engineering-process/examples/change.json +26 -0
  28. engineering_process-0.1.0.data/data/share/engineering-process/examples/plan.json +28 -0
  29. engineering_process-0.1.0.data/data/share/engineering-process/examples/project.json +54 -0
  30. engineering_process-0.1.0.data/data/share/engineering-process/examples/review.json +31 -0
  31. engineering_process-0.1.0.data/data/share/engineering-process/schemas/change.schema.json +88 -0
  32. engineering_process-0.1.0.data/data/share/engineering-process/schemas/lifecycle.schema.json +122 -0
  33. engineering_process-0.1.0.data/data/share/engineering-process/schemas/plan.schema.json +105 -0
  34. engineering_process-0.1.0.data/data/share/engineering-process/schemas/process-lock.schema.json +39 -0
  35. engineering_process-0.1.0.data/data/share/engineering-process/schemas/project.schema.json +515 -0
  36. engineering_process-0.1.0.data/data/share/engineering-process/schemas/review.schema.json +140 -0
  37. engineering_process-0.1.0.data/data/share/engineering-process/schemas/reviewer-attestation.schema.json +30 -0
  38. engineering_process-0.1.0.data/data/share/engineering-process/schemas/verification.schema.json +120 -0
  39. engineering_process-0.1.0.data/data/share/engineering-process/skills/assess-design/SKILL.md +39 -0
  40. engineering_process-0.1.0.data/data/share/engineering-process/skills/build-frontend/SKILL.md +38 -0
  41. engineering_process-0.1.0.data/data/share/engineering-process/skills/build-frontend-foundation/SKILL.md +36 -0
  42. engineering_process-0.1.0.data/data/share/engineering-process/skills/change-api/SKILL.md +36 -0
  43. engineering_process-0.1.0.data/data/share/engineering-process/skills/cross-repo-change/SKILL.md +37 -0
  44. engineering_process-0.1.0.data/data/share/engineering-process/skills/define-change-contract/SKILL.md +40 -0
  45. engineering_process-0.1.0.data/data/share/engineering-process/skills/design-module/SKILL.md +37 -0
  46. engineering_process-0.1.0.data/data/share/engineering-process/skills/evolve-process/SKILL.md +37 -0
  47. engineering_process-0.1.0.data/data/share/engineering-process/skills/finish-change/SKILL.md +34 -0
  48. engineering_process-0.1.0.data/data/share/engineering-process/skills/govern-ui/SKILL.md +40 -0
  49. engineering_process-0.1.0.data/data/share/engineering-process/skills/implement-change/SKILL.md +41 -0
  50. engineering_process-0.1.0.data/data/share/engineering-process/skills/implement-module/SKILL.md +36 -0
  51. engineering_process-0.1.0.data/data/share/engineering-process/skills/implement-use-case/SKILL.md +36 -0
  52. engineering_process-0.1.0.data/data/share/engineering-process/skills/integrate-mcp/SKILL.md +37 -0
  53. engineering_process-0.1.0.data/data/share/engineering-process/skills/maintain-docs/SKILL.md +35 -0
  54. engineering_process-0.1.0.data/data/share/engineering-process/skills/plan-change/SKILL.md +37 -0
  55. engineering_process-0.1.0.data/data/share/engineering-process/skills/publish-change/SKILL.md +38 -0
  56. engineering_process-0.1.0.data/data/share/engineering-process/skills/review-change/SKILL.md +49 -0
  57. engineering_process-0.1.0.data/data/share/engineering-process/skills/run-change/SKILL.md +51 -0
  58. engineering_process-0.1.0.data/data/share/engineering-process/skills/run-change/references/execution.md +88 -0
  59. engineering_process-0.1.0.data/data/share/engineering-process/skills/run-project-command/SKILL.md +55 -0
  60. engineering_process-0.1.0.data/data/share/engineering-process/skills/specify-use-case/SKILL.md +37 -0
  61. engineering_process-0.1.0.data/data/share/engineering-process/skills/verify-change/SKILL.md +38 -0
  62. engineering_process-0.1.0.data/data/share/engineering-process/templates/AGENTS.process.md +17 -0
  63. engineering_process-0.1.0.data/data/share/engineering-process/templates/PULL_REQUEST_TEMPLATE.md +27 -0
  64. engineering_process-0.1.0.dist-info/METADATA +387 -0
  65. engineering_process-0.1.0.dist-info/RECORD +69 -0
  66. engineering_process-0.1.0.dist-info/WHEEL +5 -0
  67. engineering_process-0.1.0.dist-info/entry_points.txt +2 -0
  68. engineering_process-0.1.0.dist-info/licenses/LICENSE +21 -0
  69. 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