cf-runtime 0.1.8__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.
@@ -0,0 +1,373 @@
1
+ """Pinned, verified acquisition of the Java and Fuseki runtime tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ import hashlib
7
+ import json
8
+ import os
9
+ from pathlib import Path, PurePosixPath
10
+ import platform
11
+ import re
12
+ import shutil
13
+ import stat
14
+ import subprocess
15
+ import tarfile
16
+ import tempfile
17
+ import time
18
+ from typing import Callable, Iterable
19
+ import urllib.parse
20
+ import urllib.request
21
+ import zipfile
22
+
23
+ from .locking import LifecycleLock
24
+ from .paths import CogniflowHome
25
+
26
+ MAX_BYTES = 512 * 1024 * 1024
27
+ MAX_FILES = 100_000
28
+ MAX_EXTRACTED = 2 * 1024 * 1024 * 1024
29
+ _REPARSE_POINT = 0x0400
30
+ _VERSION = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+\-]*$")
31
+
32
+
33
+ class ManagedToolsError(ValueError):
34
+ """Expected provisioning failure with a stable machine-readable code."""
35
+
36
+ def __init__(self, code: str, message: str) -> None:
37
+ super().__init__(message)
38
+ self.code = code
39
+
40
+
41
+ MANAGED_REQUEST_SCHEMA = "cf.runtime.managed-tools.request.v1"
42
+ MANAGED_RESULT_SCHEMA = "cf.runtime.managed-tools.result.v1"
43
+
44
+
45
+ @dataclass(frozen=True, slots=True)
46
+ class Artifact:
47
+ name: str
48
+ version: str
49
+ platform: str
50
+ architecture: str
51
+ archive_format: str
52
+ url: str
53
+ sha256: str
54
+ archive_root: str
55
+ executable: str
56
+ version_command: tuple[str, ...]
57
+ version_pattern: str
58
+ size: int
59
+
60
+ def validate(self) -> None:
61
+ if self.name not in {"java", "fuseki"} or not _VERSION.fullmatch(self.version):
62
+ raise ManagedToolsError("CATALOG_INVALID", "invalid tool identity or version")
63
+ if self.archive_format not in {"zip", "tar.gz"} or not self.archive_root or not self.executable:
64
+ raise ManagedToolsError("CATALOG_INVALID", "invalid archive layout")
65
+ parsed = urllib.parse.urlparse(self.url)
66
+ if parsed.scheme not in {"https", "file"} or not parsed.netloc and parsed.scheme == "https":
67
+ raise ManagedToolsError("CATALOG_INVALID", "artifact URL must be HTTPS or a fixture file URL")
68
+ if not re.fullmatch(r"[0-9a-f]{64}", self.sha256) or self.size <= 0 or self.size > MAX_BYTES:
69
+ raise ManagedToolsError("CATALOG_INVALID", "invalid artifact digest or size")
70
+
71
+
72
+ def normalized_platform() -> tuple[str, str]:
73
+ system = platform.system().lower()
74
+ machine = platform.machine().lower()
75
+ if system == "windows" and machine in {"amd64", "x86_64"}:
76
+ return "windows", "x86_64"
77
+ if system == "linux" and machine in {"amd64", "x86_64"}:
78
+ return "linux", "x86_64"
79
+ raise ManagedToolsError("UNSUPPORTED_PLATFORM", "only Windows/Linux x86_64 are supported")
80
+
81
+
82
+ def _artifact(name: str, version: str, plat: str, arch: str, fmt: str, url: str,
83
+ digest: str, root: str, executable: str, command: tuple[str, ...],
84
+ pattern: str, size: int) -> Artifact:
85
+ return Artifact(name, version, plat, arch, fmt, url, digest, root, executable, command, pattern, size)
86
+
87
+
88
+ def catalog() -> tuple[Artifact, ...]:
89
+ java_version = "21.0.10+7"
90
+ base = "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.10%2B7/"
91
+ java = {
92
+ ("windows", "x86_64"): _artifact("java", java_version, "windows", "x86_64", "zip", base + "OpenJDK21U-jdk_x64_windows_hotspot_21.0.10_7.zip", "08cae782814f027f8b159d6b68823f0f87422eb475c1a0ea1abc7a4357aaf11f", "jdk-21.0.10+7", "bin/java.exe", ("-version",), r"21\.0\.10", 204_939_612),
93
+ ("linux", "x86_64"): _artifact("java", java_version, "linux", "x86_64", "tar.gz", base + "OpenJDK21U-jdk_x64_linux_hotspot_21.0.10_7.tar.gz", "ea3b9bd464d6dd253e9a7accf59f7ccd2a36e4aa69640b7251e3370caef896a4", "jdk-21.0.10+7", "bin/java", ("-version",), r"21\.0\.10", 207_216_881),
94
+ }
95
+ fuseki_base = "https://archive.apache.org/dist/jena/binaries/apache-jena-fuseki-6.0.0.zip"
96
+ fuseki = _artifact("fuseki", "6.0.0", "any", "x86_64", "zip", fuseki_base, "08285d130def0928e4a414e6750a09a2924c74407f395b133e914575c722b7ef", "apache-jena-fuseki-6.0.0", "fuseki-server.jar", ("--version",), r"6\.0\.0", 51_253_805)
97
+ # The Apache checksum is resolved once from the official release archive.
98
+ # A missing digest intentionally makes normal provisioning fail closed.
99
+ return (*java.values(), fuseki)
100
+
101
+
102
+ def bind_release_lock(records: object) -> tuple[Artifact, ...]:
103
+ """Bind release-profile identities to the executable runtime catalog."""
104
+ if not isinstance(records, list):
105
+ raise ManagedToolsError("RELEASE_LOCK_INVALID", "managed-tool release lock must be a list")
106
+ expected = {
107
+ "fuseki": next(item for item in catalog() if item.name == "fuseki"),
108
+ "java-linux-x86_64": next(item for item in catalog() if item.name == "java" and item.platform == "linux"),
109
+ "java-windows-x86_64": next(item for item in catalog() if item.name == "java" and item.platform == "windows"),
110
+ }
111
+ if [item.get("name") if isinstance(item, dict) else None for item in records] != sorted(expected):
112
+ raise ManagedToolsError("RELEASE_LOCK_INVALID", "managed-tool release lock is incomplete")
113
+ for record in records:
114
+ if not isinstance(record, dict) or set(record) != {"name", "version", "url", "sha256", "size"}:
115
+ raise ManagedToolsError("RELEASE_LOCK_INVALID", "managed-tool release lock entry is invalid")
116
+ spec = expected[record["name"]]
117
+ actual = {key: record[key] for key in ("version", "url", "sha256", "size")}
118
+ locked = {"version": spec.version, "url": spec.url, "sha256": spec.sha256, "size": spec.size}
119
+ if actual != locked:
120
+ raise ManagedToolsError("RELEASE_LOCK_DIVERGED", f"runtime catalog diverges from release lock for {record['name']}")
121
+ return tuple(expected[name] for name in sorted(expected))
122
+
123
+
124
+ def _safe_relative(value: str) -> PurePosixPath:
125
+ value = value.replace("\\", "/")
126
+ path = PurePosixPath(value)
127
+ if path.is_absolute() or value.startswith(("/", "//")) or re.match(r"^[A-Za-z]:", value) or ".." in path.parts:
128
+ raise ManagedToolsError("ARCHIVE_UNSAFE", "archive contains an escaping path")
129
+ if not path.parts or any(not part or part == "." for part in path.parts):
130
+ raise ManagedToolsError("ARCHIVE_UNSAFE", "archive contains an invalid path")
131
+ return path
132
+
133
+
134
+ def _safe_link_target(member_name: str, linkname: str, expected_root: str) -> PurePosixPath:
135
+ target = linkname.replace("\\", "/")
136
+ if target.startswith(("/", "//")) or re.match(r"^[A-Za-z]:", target):
137
+ raise ManagedToolsError("ARCHIVE_LINK", "symlink escapes archive root")
138
+ parts = list(PurePosixPath(member_name).parent.parts)
139
+ for part in PurePosixPath(target).parts:
140
+ if part in {"", "."}:
141
+ continue
142
+ if part == "..":
143
+ if not parts or parts[-1] == expected_root:
144
+ raise ManagedToolsError("ARCHIVE_LINK", "symlink escapes archive root")
145
+ parts.pop()
146
+ else:
147
+ parts.append(part)
148
+ if not parts or parts[0] != expected_root:
149
+ raise ManagedToolsError("ARCHIVE_LINK", "symlink escapes archive root")
150
+ return PurePosixPath(*parts)
151
+
152
+
153
+ def _exists_without_following(path: Path) -> bool:
154
+ return os.path.lexists(path)
155
+
156
+
157
+ def _is_redirect(path: Path) -> bool:
158
+ if path.is_symlink():
159
+ return True
160
+ if os.name == "nt":
161
+ try:
162
+ return bool(os.lstat(path).st_file_attributes & _REPARSE_POINT)
163
+ except (AttributeError, FileNotFoundError, OSError):
164
+ return False
165
+ return False
166
+
167
+
168
+ def _validate_names(names: Iterable[str], expected_root: str) -> list[PurePosixPath]:
169
+ paths = [_safe_relative(name) for name in names]
170
+ if not paths or any(path.parts[0] != expected_root for path in paths):
171
+ raise ManagedToolsError("ARCHIVE_ROOT_INVALID", "unexpected archive root")
172
+ folded: set[str] = set()
173
+ for path in paths:
174
+ key = "/".join(path.parts).casefold()
175
+ if key in folded:
176
+ raise ManagedToolsError("ARCHIVE_DUPLICATE", "duplicate or case-colliding archive path")
177
+ folded.add(key)
178
+ return paths
179
+
180
+
181
+ def _validate_zip(archive: Path, spec: Artifact) -> None:
182
+ with zipfile.ZipFile(archive) as zf:
183
+ infos = zf.infolist()
184
+ if len(infos) > MAX_FILES or sum(max(0, i.file_size) for i in infos) > MAX_EXTRACTED:
185
+ raise ManagedToolsError("ARCHIVE_LIMIT", "archive exceeds extraction limits")
186
+ _validate_names((i.filename for i in infos), spec.archive_root)
187
+ for info in infos:
188
+ mode = (info.external_attr >> 16) & 0o170000
189
+ if mode in {stat.S_IFLNK, stat.S_IFCHR, stat.S_IFBLK, stat.S_IFIFO, stat.S_IFSOCK}:
190
+ raise ManagedToolsError("ARCHIVE_LINK", "links and special files are not allowed")
191
+
192
+
193
+ def _validate_tar(archive: Path, spec: Artifact) -> None:
194
+ with tarfile.open(archive, "r:gz") as tf:
195
+ members = tf.getmembers()
196
+ if len(members) > MAX_FILES or sum(max(0, m.size) for m in members) > MAX_EXTRACTED:
197
+ raise ManagedToolsError("ARCHIVE_LIMIT", "archive exceeds extraction limits")
198
+ _validate_names((m.name for m in members), spec.archive_root)
199
+ for member in members:
200
+ if member.issym():
201
+ _safe_link_target(member.name, member.linkname, spec.archive_root)
202
+ elif member.islnk() or not (member.isfile() or member.isdir()):
203
+ raise ManagedToolsError("ARCHIVE_LINK", "hardlinks and special files are not allowed")
204
+
205
+
206
+ def _extract(archive: Path, spec: Artifact, destination: Path) -> None:
207
+ if spec.archive_format == "zip":
208
+ _validate_zip(archive, spec)
209
+ with zipfile.ZipFile(archive) as zf:
210
+ zf.extractall(destination)
211
+ else:
212
+ _validate_tar(archive, spec)
213
+ with tarfile.open(archive, "r:gz") as tf:
214
+ tf.extractall(destination, filter="data")
215
+
216
+
217
+ def _run(command: list[str], cwd: Path, timeout: float, env: dict[str, str]) -> str:
218
+ try:
219
+ result = subprocess.run(command, cwd=cwd, env=env, capture_output=True, text=True,
220
+ timeout=timeout, check=False, encoding="utf-8", errors="replace")
221
+ except (OSError, subprocess.TimeoutExpired) as error:
222
+ raise ManagedToolsError("TOOL_INVALID", "managed tool command failed") from error
223
+ output = (result.stdout + "\n" + result.stderr)[:16_384]
224
+ if result.returncode != 0:
225
+ raise ManagedToolsError("TOOL_INVALID", "managed tool returned a failure")
226
+ return output
227
+
228
+
229
+ class ManagedTools:
230
+ def __init__(self, home: CogniflowHome, *, artifacts: Iterable[Artifact] | None = None,
231
+ opener: Callable[..., object] = urllib.request.urlopen,
232
+ lock_timeout: float = 0.0) -> None:
233
+ self.home = home
234
+ self.artifacts = tuple(artifacts or catalog())
235
+ self.opener = opener
236
+ self.lock_timeout = lock_timeout
237
+
238
+ def _find(self, name: str, version: str) -> Artifact:
239
+ plat, arch = normalized_platform()
240
+ matches = [a for a in self.artifacts if a.name == name and a.version == version and
241
+ (a.platform, a.architecture) in {(plat, arch), ("any", arch)}]
242
+ if len(matches) != 1:
243
+ raise ManagedToolsError("ARTIFACT_UNAVAILABLE", f"no exact {name} artifact for {plat}/{arch}")
244
+ matches[0].validate()
245
+ return matches[0]
246
+
247
+ def _download(self, spec: Artifact) -> Path:
248
+ cache = self.home.tool_cache
249
+ cache.mkdir(parents=True, exist_ok=True)
250
+ suffix = ".zip" if spec.archive_format == "zip" else ".tar.gz"
251
+ target = cache / f"{spec.name}-{spec.version}-{spec.platform}-{spec.architecture}{suffix}"
252
+ if target.is_file() and target.stat().st_size == spec.size and _sha256(target) == spec.sha256:
253
+ return target
254
+ temporary = cache / f".{target.name}.{os.getpid()}.{time.time_ns()}.part"
255
+ try:
256
+ request = urllib.request.Request(spec.url, headers={"User-Agent": "cogniflow-cf-runtime/0.1"})
257
+ with self.opener(request, timeout=30) as response, temporary.open("wb") as stream:
258
+ digest = hashlib.sha256()
259
+ total = 0
260
+ while True:
261
+ chunk = response.read(1024 * 1024)
262
+ if not chunk:
263
+ break
264
+ total += len(chunk)
265
+ if total > spec.size or total > MAX_BYTES:
266
+ raise ManagedToolsError("DOWNLOAD_TOO_LARGE", "download exceeds catalog size")
267
+ digest.update(chunk)
268
+ stream.write(chunk)
269
+ stream.flush(); os.fsync(stream.fileno())
270
+ if total != spec.size or digest.hexdigest() != spec.sha256:
271
+ raise ManagedToolsError("CHECKSUM_MISMATCH", "download digest or size does not match catalog")
272
+ os.replace(temporary, target)
273
+ return target
274
+ finally:
275
+ temporary.unlink(missing_ok=True)
276
+
277
+ def provision(self, java_version: str, fuseki_version: str) -> dict[str, object]:
278
+ if not isinstance(java_version, str) or not isinstance(fuseki_version, str):
279
+ raise ManagedToolsError("INVALID_REQUEST", "tool versions must be strings")
280
+ java = self._find("java", java_version)
281
+ fuseki = self._find("fuseki", fuseki_version)
282
+ with LifecycleLock(self.home.tool_locks, self.lock_timeout):
283
+ self.home.root.mkdir(parents=True, exist_ok=True)
284
+ java_root = self.home.java_install(java.version)
285
+ fuseki_root = self.home.fuseki_install(fuseki.version)
286
+ java_result = self._install(java, java_root, "java")
287
+ fuseki_result = self._install(fuseki, fuseki_root, "fuseki")
288
+ return {"schema": MANAGED_RESULT_SCHEMA,
289
+ "status": "unchanged" if java_result == fuseki_result == "unchanged" else "ready",
290
+ "java": self._result(java, java_root, java_root / java.executable, "java"),
291
+ "fuseki": self._result(fuseki, fuseki_root, fuseki_root / fuseki.executable, "fuseki")}
292
+
293
+ def _install(self, spec: Artifact, final: Path, kind: str) -> str:
294
+ if _exists_without_following(final) and _is_redirect(final):
295
+ raise ManagedToolsError("FOREIGN_INSTALLATION", f"managed {kind} path is a redirect")
296
+ marker = final / ".cf-managed.json"
297
+ if _exists_without_following(final) and not final.is_dir():
298
+ raise ManagedToolsError("FOREIGN_INSTALLATION", f"managed {kind} path is not a directory")
299
+ if _exists_without_following(final) and marker.is_file() and not _is_redirect(marker):
300
+ try:
301
+ data = json.loads(marker.read_text(encoding="utf-8"))
302
+ if data == {"name": spec.name, "version": spec.version, "sha256": spec.sha256}:
303
+ self._validate_install(final, spec, kind)
304
+ return "unchanged"
305
+ except (OSError, ValueError, json.JSONDecodeError):
306
+ pass
307
+ if _exists_without_following(final):
308
+ raise ManagedToolsError("FOREIGN_INSTALLATION", f"managed {kind} path is not owned by cf_runtime")
309
+ archive = self._download(spec)
310
+ staging = final.parent / f".{final.name}.{os.getpid()}.{time.time_ns()}.staging"
311
+ try:
312
+ final.parent.mkdir(parents=True, exist_ok=True)
313
+ staging.mkdir(parents=True)
314
+ _extract(archive, spec, staging)
315
+ source = staging / spec.archive_root
316
+ if not source.is_dir():
317
+ raise ManagedToolsError("ARCHIVE_ROOT_INVALID", "archive root is not a directory")
318
+ os.replace(source, staging / "payload")
319
+ payload = staging / "payload"
320
+ self._validate_install(payload, spec, kind)
321
+ (payload / ".cf-managed.json").write_text(json.dumps({"name": spec.name, "version": spec.version, "sha256": spec.sha256}, sort_keys=True) + "\n", encoding="utf-8")
322
+ os.replace(payload, final)
323
+ return "ready"
324
+ finally:
325
+ shutil.rmtree(staging, ignore_errors=True)
326
+
327
+ def _validate_install(self, root: Path, spec: Artifact, kind: str) -> None:
328
+ candidate = root / spec.executable
329
+ current = root
330
+ for part in candidate.relative_to(root).parts:
331
+ current = current / part
332
+ if current.is_symlink():
333
+ raise ManagedToolsError("TOOL_INVALID", f"managed {kind} executable is a symlink")
334
+ path = candidate.resolve(strict=False)
335
+ if not path.is_file() or root.resolve() not in path.parents:
336
+ raise ManagedToolsError("TOOL_INVALID", f"managed {kind} executable is invalid")
337
+ if kind == "java":
338
+ env = {k: v for k, v in os.environ.items() if k not in {"JAVA_HOME", "JDK_HOME"}}
339
+ output = _run([str(path), *spec.version_command], root, 10, env)
340
+ else:
341
+ java = self.home.java_install("21.0.10+7") / "bin" / ("java.exe" if os.name == "nt" else "java")
342
+ if not java.is_file() or java.resolve().is_symlink():
343
+ raise ManagedToolsError("TOOL_INVALID", "managed Java is required for Fuseki verification")
344
+ output = _run([str(java), "-jar", str(path), *spec.version_command], root, 10, {})
345
+ if not re.search(spec.version_pattern, output):
346
+ raise ManagedToolsError("VERSION_MISMATCH", f"{kind} reported an unexpected version")
347
+
348
+ @staticmethod
349
+ def _result(spec: Artifact, root: Path, executable: Path, kind: str) -> dict[str, object]:
350
+ lock_record = {
351
+ "name": spec.name,
352
+ "version": spec.version,
353
+ "platform": spec.platform,
354
+ "architecture": spec.architecture,
355
+ "archive_format": spec.archive_format,
356
+ "url": spec.url,
357
+ "sha256": spec.sha256,
358
+ "size": spec.size,
359
+ }
360
+ lock_digest = "sha256:" + hashlib.sha256(
361
+ (json.dumps(lock_record, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8")
362
+ ).hexdigest()
363
+ result = {"version": spec.version, "home": str(root), "artifact_sha256": spec.sha256, "lock_record": lock_record, "lock_digest": lock_digest}
364
+ result["executable" if kind == "java" else "server_artifact"] = str(executable)
365
+ return result
366
+
367
+
368
+ def _sha256(path: Path) -> str:
369
+ digest = hashlib.sha256()
370
+ with path.open("rb") as stream:
371
+ for chunk in iter(lambda: stream.read(1024 * 1024), b""):
372
+ digest.update(chunk)
373
+ return digest.hexdigest()