ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
managed_python_env.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# SPDX-License-Identifier: MIT
3# Copyright (c) 2026 Brighton Sikarskie
4"""Create and verify the root-owned managed Python environment receipt."""
5
6from __future__ import annotations
7
8import argparse
9import contextlib
10import hashlib
11import importlib.util
12import json
13import os
14import re
15import shutil
16import stat
17import sys
18import tempfile
19import time
20import unicodedata
21from collections.abc import Callable
22from dataclasses import dataclass
23from pathlib import Path
24from types import ModuleType
25from typing import Any, NoReturn, Protocol
26
27
28class ManagedEnvironmentError(RuntimeError):
29 """Report a failed managed-environment authentication check."""
30
31
32@dataclass(frozen=True)
33class TrustPolicy:
34 """Describe the owners accepted while checking filesystem objects."""
35
36 environment_uid: int
37 route_uids: frozenset[int]
38
39 @classmethod
40 def production(cls) -> TrustPolicy:
41 """Return the root-only production trust policy."""
42 return cls(environment_uid=0, route_uids=frozenset({0}))
43
44 @classmethod
45 def selftest(cls) -> TrustPolicy:
46 """Permit the current account only inside the private selftest tree."""
47 uid = os.getuid()
48 return cls(environment_uid=uid, route_uids=frozenset({0, uid}))
49
50
51@dataclass(frozen=True)
52class ObjectIdentity:
53 """Capture fields whose change means a checked object was replaced."""
54
55 device: int
56 inode: int
57 mode: int
58 uid: int
59 size: int
60 mtime_ns: int
61 ctime_ns: int
62
63 @classmethod
64 def from_stat(cls, value: os.stat_result) -> ObjectIdentity:
65 """Build an identity from one stat result."""
66 return cls(
67 device=value.st_dev,
68 inode=value.st_ino,
69 mode=value.st_mode,
70 uid=value.st_uid,
71 size=value.st_size,
72 mtime_ns=value.st_mtime_ns,
73 ctime_ns=value.st_ctime_ns,
74 )
75
76 def cache_fields(self) -> tuple[int, ...]:
77 """Return every identity field used to invalidate a warm authentication."""
78 return (
79 self.device,
80 self.inode,
81 self.mode,
82 self.uid,
83 self.size,
84 self.mtime_ns,
85 self.ctime_ns,
86 )
87
88
89@dataclass(frozen=True)
90class InterpreterProbe:
91 """Hold identity facts reported by the authenticated interpreter."""
92
93 implementation: str
94 version: str
95 installed_sha256: str
96
97
98@dataclass(frozen=True)
99class EnvironmentTreeIdentity:
100 """Bind every trusted object and regular-file byte under the environment."""
101
102 sha256: str
103 entries: int
104 regular_file_bytes: int
105
106
107@dataclass(frozen=True)
108class TreeTrustContext:
109 """Hold the trust boundary used while authenticating nested tree objects."""
110
111 environment: Path
112 interpreter_target: Path
113 policy: TrustPolicy
114
115
116class DigestWriter(Protocol):
117 """Describe the only hash-object operation used by record framing."""
118
119 def update(self, data: bytes) -> None:
120 """Append bytes to the digest state."""
121
122
123@dataclass(frozen=True)
124class CommandResult:
125 """Hold captured output and the normalized process status."""
126
127 returncode: int
128 stdout: str
129 stderr: str
130
131
132@dataclass(frozen=True)
133class ReceiptSources:
134 """Name the environment and locked inputs bound into one receipt."""
135
136 environment: Path
137 pyproject: Path
138 lockfile: Path
139 group: str
140
141
142def _run_command(
143 arguments: list[str], environment: dict[str, str], timeout_seconds: int
144) -> CommandResult:
145 """Run one absolute executable without a shell and capture both streams."""
146 executable = Path(arguments[0])
147 if not executable.is_absolute():
148 _fail(f"refusing to execute a non-absolute command: {executable}")
149 with tempfile.TemporaryFile() as stdout_file, tempfile.TemporaryFile() as stderr_file:
150 actions = (
151 (os.POSIX_SPAWN_DUP2, stdout_file.fileno(), 1),
152 (os.POSIX_SPAWN_DUP2, stderr_file.fileno(), 2),
153 )
154 try:
155 process = os.posix_spawn(str(executable), arguments, environment, file_actions=actions)
156 except OSError as error:
157 _fail(f"cannot execute {executable}: {error}")
158 deadline = time.monotonic() + timeout_seconds
159 status = 0
160 while True:
161 waited, status = os.waitpid(process, os.WNOHANG)
162 if waited == process:
163 break
164 if time.monotonic() >= deadline:
165 os.kill(process, 9)
166 os.waitpid(process, 0)
167 _fail(f"command timed out after {timeout_seconds}s: {executable}")
168 time.sleep(0.01)
169 stdout_file.seek(0)
170 stderr_file.seek(0)
171 stdout = stdout_file.read().decode("utf-8", errors="replace")
172 stderr = stderr_file.read().decode("utf-8", errors="replace")
173 returncode = os.waitstatus_to_exitcode(status)
174 return CommandResult(returncode, stdout, stderr)
175
176
177RECEIPT_NAME = ".ra8-managed-python-v1.json"
178RECEIPT_MODE = 0o444
179
180
181def _fail(message: str) -> NoReturn:
182 """Raise one consistently typed authentication failure."""
183 raise ManagedEnvironmentError(message)
184
185
186def _load_checks() -> ModuleType:
187 """Load the adjacent QA helper explicitly even under isolated Python mode."""
188 name = "_ra8_managed_python_env_checks"
189 loaded = sys.modules.get(name)
190 if isinstance(loaded, ModuleType):
191 return loaded
192 path = Path(__file__).with_name("managed_python_env_checks.py")
193 spec = importlib.util.spec_from_file_location(name, path)
194 if spec is None or spec.loader is None:
195 _fail(f"cannot load managed-environment QA helper: {path}")
196 module = importlib.util.module_from_spec(spec)
197 sys.modules[name] = module
198 spec.loader.exec_module(module)
199 return module
200
201
202def _identity(path: Path, *, follow_symlinks: bool = False) -> ObjectIdentity:
203 """Read an object's stable identity without following links by default."""
204 try:
205 value = path.stat() if follow_symlinks else path.lstat()
206 except OSError as error:
207 _fail(f"cannot stat {path}: {error}")
208 return ObjectIdentity.from_stat(value)
209
210
211def _require_owner_mode(
212 path: Path,
213 identity: ObjectIdentity,
214 owners: frozenset[int],
215 *,
216 allow_symlink: bool = False,
217) -> None:
218 """Reject an unsafe owner, writable route, or unexpected object kind."""
219 if identity.uid not in owners:
220 _fail(f"untrusted owner for {path}: uid {identity.uid}")
221 if stat.S_ISLNK(identity.mode):
222 if not allow_symlink:
223 _fail(f"symlink is forbidden in managed environment route: {path}")
224 return
225 if identity.mode & 0o022:
226 _fail(f"group/other-writable managed environment route: {path}")
227
228
229def _path_components(path: Path) -> list[Path]:
230 """Return every absolute route component from root through path."""
231 components = [Path(path.anchor)]
232 current = Path(path.anchor)
233 for part in path.parts[1:]:
234 current /= part
235 components.append(current)
236 return components
237
238
239def _validate_route(path: Path, owners: frozenset[int]) -> dict[Path, ObjectIdentity]:
240 """Validate every route component and return its pre-use identity."""
241 snapshots: dict[Path, ObjectIdentity] = {}
242 for component in _path_components(path):
243 identity = _identity(component)
244 if not stat.S_ISDIR(identity.mode):
245 _fail(f"managed environment route component is not a directory: {component}")
246 _require_owner_mode(component, identity, owners)
247 snapshots[component] = identity
248 return snapshots
249
250
251def _canonical_environment(raw: str) -> Path:
252 """Require an existing absolute path already in canonical spelling."""
253 if not raw or not Path(raw).is_absolute() or raw == os.sep:
254 _fail(f"managed environment must be a non-root absolute path: {raw!r}")
255 if any(unicodedata.category(character) == "Cc" for character in raw):
256 _fail("managed environment path contains a control character")
257 normalized = os.path.normpath(raw)
258 if raw != normalized:
259 _fail(f"managed environment path is not canonical: {raw}")
260 try:
261 resolved = str(Path(raw).resolve(strict=True))
262 except OSError as error:
263 _fail(f"managed environment does not resolve: {raw}: {error}")
264 if resolved != raw:
265 _fail(f"managed environment route contains a symlink: {raw} -> {resolved}")
266 return Path(raw)
267
268
269def _safe_file_bytes(path: Path) -> tuple[bytes, ObjectIdentity]:
270 """Read one regular file through a no-follow descriptor and recheck it."""
271 flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
272 try:
273 descriptor = os.open(path, flags)
274 except OSError as error:
275 _fail(f"cannot open trusted file {path}: {error}")
276 try:
277 before = ObjectIdentity.from_stat(os.fstat(descriptor))
278 if not stat.S_ISREG(before.mode):
279 _fail(f"trusted file is not regular: {path}")
280 chunks: list[bytes] = []
281 while True:
282 chunk = os.read(descriptor, 1024 * 1024)
283 if not chunk:
284 break
285 chunks.append(chunk)
286 after = ObjectIdentity.from_stat(os.fstat(descriptor))
287 if before != after:
288 _fail(f"trusted file changed while it was read: {path}")
289 return b"".join(chunks), before
290 finally:
291 os.close(descriptor)
292
293
294def _sha256_file(path: Path) -> str:
295 """Hash one no-follow regular file."""
296 data, _identity_value = _safe_file_bytes(path)
297 return hashlib.sha256(data).hexdigest()
298
299
300def _tree_digest_record(
301 digest: DigestWriter,
302 kind: bytes,
303 relative: Path,
304 identity: ObjectIdentity,
305 payload: bytes,
306) -> None:
307 """Append one length-delimited filesystem record to the tree digest."""
308 fields = (
309 kind,
310 os.fsencode(str(relative)),
311 stat.S_IMODE(identity.mode).to_bytes(4, "big"),
312 identity.uid.to_bytes(8, "big"),
313 (identity.size if kind != b"d" else 0).to_bytes(8, "big"),
314 payload,
315 )
316 for field in fields:
317 digest.update(len(field).to_bytes(8, "big"))
318 digest.update(field)
319
320
321def _trusted_regular_file_digest(path: Path, policy: TrustPolicy) -> tuple[bytes, ObjectIdentity]:
322 """Hash one immutable environment file while holding a no-follow descriptor."""
323 flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
324 try:
325 descriptor = os.open(path, flags)
326 except OSError as error:
327 _fail(f"cannot open managed environment file {path}: {error}")
328 try:
329 before = ObjectIdentity.from_stat(os.fstat(descriptor))
330 if not stat.S_ISREG(before.mode):
331 _fail(f"managed environment file is not regular: {path}")
332 _require_owner_mode(path, before, frozenset({policy.environment_uid}))
333 digest = hashlib.sha256()
334 remaining = before.size
335 while remaining:
336 chunk = os.read(descriptor, min(1024 * 1024, remaining))
337 if not chunk:
338 _fail(f"managed environment file was truncated while hashing: {path}")
339 digest.update(chunk)
340 remaining -= len(chunk)
341 if os.read(descriptor, 1):
342 _fail(f"managed environment file grew while hashing: {path}")
343 after = ObjectIdentity.from_stat(os.fstat(descriptor))
344 if before != after:
345 _fail(f"managed environment file changed while hashing: {path}")
346 return digest.digest(), before
347 finally:
348 os.close(descriptor)
349
350
351def _trusted_tree_symlink(
352 path: Path,
353 relative: Path,
354 identity: ObjectIdentity,
355 context: TreeTrustContext,
356) -> bytes:
357 """Validate one link target and return its exact spelling for the digest."""
358 _require_owner_mode(
359 path,
360 identity,
361 frozenset({context.policy.environment_uid}),
362 allow_symlink=True,
363 )
364 try:
365 spelling = os.fsencode(path.readlink())
366 resolved = path.resolve(strict=True)
367 except OSError as error:
368 _fail(f"cannot resolve managed environment symlink {path}: {error}")
369 try:
370 resolved.relative_to(context.environment)
371 except ValueError:
372 python_link = relative.parent == Path("bin") and re.fullmatch(
373 r"python(?:3(?:\.\d+)?)?", relative.name
374 )
375 if not python_link or resolved != context.interpreter_target:
376 _fail(f"managed environment symlink escapes its root: {path} -> {resolved}")
377 return spelling
378
379
380def _environment_tree_identity(
381 environment: Path, interpreter_target: Path, policy: TrustPolicy
382) -> EnvironmentTreeIdentity:
383 """Authenticate and hash the bounded environment tree without following links."""
384 maximum_entries = 100_000
385 maximum_bytes = 2 * 1024 * 1024 * 1024
386 digest = hashlib.sha256(b"ra8-managed-python-tree-v1\0")
387 context = TreeTrustContext(environment, interpreter_target, policy)
388 pending = [environment]
389 entries = 0
390 regular_file_bytes = 0
391 while pending:
392 directory = pending.pop()
393 directory_identity = _identity(directory)
394 if not stat.S_ISDIR(directory_identity.mode):
395 _fail(f"managed environment tree entry is not a directory: {directory}")
396 _require_owner_mode(directory, directory_identity, frozenset({policy.environment_uid}))
397 relative_directory = directory.relative_to(environment)
398 _tree_digest_record(digest, b"d", relative_directory, directory_identity, b"")
399 entries += 1
400 if entries > maximum_entries:
401 _fail("managed environment exceeds the authenticated tree bounds")
402 try:
403 children = sorted(directory.iterdir(), key=lambda item: os.fsencode(item.name))
404 except OSError as error:
405 _fail(f"cannot enumerate managed environment directory {directory}: {error}")
406 for child in reversed(children):
407 relative = child.relative_to(environment)
408 if relative == Path(RECEIPT_NAME):
409 continue
410 identity = _identity(child)
411 if stat.S_ISDIR(identity.mode):
412 pending.append(child)
413 continue
414 if stat.S_ISREG(identity.mode):
415 content_digest, stable_identity = _trusted_regular_file_digest(child, policy)
416 _tree_digest_record(digest, b"f", relative, stable_identity, content_digest)
417 regular_file_bytes += stable_identity.size
418 elif stat.S_ISLNK(identity.mode):
419 spelling = _trusted_tree_symlink(child, relative, identity, context)
420 _tree_digest_record(digest, b"l", relative, identity, spelling)
421 else:
422 _fail(f"unsupported object in managed environment tree: {child}")
423 entries += 1
424 if entries > maximum_entries or regular_file_bytes > maximum_bytes:
425 _fail("managed environment exceeds the authenticated tree bounds")
426 return EnvironmentTreeIdentity(digest.hexdigest(), entries, regular_file_bytes)
427
428
429def _interpreter_chain(
430 interpreter: Path, policy: TrustPolicy
431) -> tuple[Path, dict[Path, ObjectIdentity]]:
432 """Resolve and validate every symlink object in the interpreter chain."""
433 current = interpreter
434 snapshots: dict[Path, ObjectIdentity] = {}
435 seen: set[Path] = set()
436 for _hop in range(32):
437 if current in seen:
438 _fail(f"interpreter symlink loop: {current}")
439 seen.add(current)
440 snapshots.update(_validate_route(current.parent, policy.route_uids))
441 identity = _identity(current)
442 snapshots[current] = identity
443 _require_owner_mode(current, identity, policy.route_uids, allow_symlink=True)
444 if stat.S_ISLNK(identity.mode):
445 target = current.readlink()
446 current = target if target.is_absolute() else current.parent / target
447 current = Path(os.path.normpath(current))
448 continue
449 if not stat.S_ISREG(identity.mode) or not identity.mode & 0o111:
450 _fail(f"managed interpreter target is not executable and regular: {current}")
451 return current, snapshots
452 _fail(f"managed interpreter symlink chain is too deep: {interpreter}")
453
454
455def _check_snapshots(snapshots: dict[Path, ObjectIdentity]) -> None:
456 """Reject any checked object whose identity changed after validation."""
457 for path, before in snapshots.items():
458 after = _identity(path)
459 if stat.S_ISDIR(before.mode):
460 stable = (before.device, before.inode, before.mode, before.uid)
461 current = (after.device, after.inode, after.mode, after.uid)
462 else:
463 stable = before
464 current = after
465 if current != stable:
466 _fail(f"managed environment object changed during verification: {path}")
467
468
469def _probe_interpreter(interpreter: Path, environment: Path) -> InterpreterProbe:
470 """Query only standard-library facts after interpreter bytes are trusted."""
471 probe = """
472import hashlib
473import importlib.metadata
474import json
475import re
476import sys
477
478def canonical(name):
479 return re.sub(r"[-_.]+", "-", name).lower()
480
481packages = sorted(
482 f"{canonical(dist.metadata['Name'])}=={dist.version}"
483 for dist in importlib.metadata.distributions()
484)
485payload = {
486 "base_prefix": sys.base_prefix,
487 "executable": sys.executable,
488 "implementation": sys.implementation.name,
489 "packages_sha256": hashlib.sha256(
490 json.dumps(packages, separators=(",", ":"), ensure_ascii=True).encode("ascii")
491 ).hexdigest(),
492 "prefix": sys.prefix,
493 "version": ".".join(str(value) for value in sys.version_info[:3]),
494}
495print(json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True))
496"""
497 clean_env = {
498 "HOME": "/",
499 "LANG": "C",
500 "LC_ALL": "C",
501 "PATH": "/usr/bin:/bin",
502 "PYTHONNOUSERSITE": "1",
503 }
504 result = _run_command([str(interpreter), "-I", "-c", probe], clean_env, 30)
505 if result.returncode != 0:
506 _fail(f"managed interpreter probe failed with exit {result.returncode}")
507 try:
508 payload = json.loads(result.stdout)
509 except json.JSONDecodeError as error:
510 _fail(f"managed interpreter returned malformed identity: {error}")
511 if not isinstance(payload, dict):
512 _fail("managed interpreter returned a non-object identity")
513 prefix = str(Path(str(payload.get("prefix", ""))).resolve())
514 executable = str(Path(str(payload.get("executable", ""))).resolve())
515 if prefix != str(environment) or payload.get("base_prefix") == payload.get("prefix"):
516 _fail("managed interpreter is not bound to the declared virtual environment")
517 if executable != str(interpreter.resolve()):
518 _fail("managed interpreter reported a different executable target")
519 implementation = payload.get("implementation")
520 version = payload.get("version")
521 packages_sha256 = payload.get("packages_sha256")
522 if implementation != "cpython" or not re.fullmatch(r"3\.(11|12|13|14)\.\d+", str(version)):
523 _fail(f"unsupported managed Python identity: {implementation} {version}")
524 if not re.fullmatch(r"[0-9a-f]{64}", str(packages_sha256)):
525 _fail("managed interpreter returned an invalid installed-set digest")
526 return InterpreterProbe(str(implementation), str(version), str(packages_sha256))
527
528
529def _receipt_payload(
530 sources: ReceiptSources,
531 interpreter_target: Path,
532 probe: InterpreterProbe,
533 tree: EnvironmentTreeIdentity,
534) -> dict[str, Any]:
535 """Build the complete deterministic receipt payload."""
536 return {
537 "authority_sha256": _sha256_file(Path(__file__)),
538 "checks_sha256": _sha256_file(Path(__file__).with_name("managed_python_env_checks.py")),
539 "dependency_group": sources.group,
540 "environment_path": str(sources.environment),
541 "environment_tree_entries": tree.entries,
542 "environment_tree_regular_file_bytes": tree.regular_file_bytes,
543 "environment_tree_sha256": tree.sha256,
544 "installed_distributions_sha256": probe.installed_sha256,
545 "interpreter_target": str(interpreter_target),
546 "pyproject_sha256": _sha256_file(sources.pyproject),
547 "python_implementation": probe.implementation,
548 "python_version": probe.version,
549 "receipt_version": 1,
550 "uv_lock_sha256": _sha256_file(sources.lockfile),
551 }
552
553
554def _collect_payload(
555 environment_raw: str,
556 pyproject: Path,
557 lockfile: Path,
558 group: str,
559 policy: TrustPolicy,
560) -> tuple[Path, dict[str, Any]]:
561 """Authenticate the environment route and collect its current identity."""
562 if not re.fullmatch(r"[a-z][a-z0-9-]{0,31}", group):
563 _fail(f"invalid dependency group identity: {group!r}")
564 environment = _canonical_environment(environment_raw)
565 snapshots = _validate_route(environment, policy.route_uids)
566 environment_identity = snapshots[environment]
567 if environment_identity.uid != policy.environment_uid:
568 _fail(f"managed environment is not owned by uid {policy.environment_uid}: {environment}")
569 bin_dir = environment / "bin"
570 bin_identity = _identity(bin_dir)
571 if not stat.S_ISDIR(bin_identity.mode) or bin_identity.uid != policy.environment_uid:
572 _fail(f"managed environment bin directory has an untrusted owner: {bin_dir}")
573 _require_owner_mode(bin_dir, bin_identity, frozenset({policy.environment_uid}))
574 snapshots[bin_dir] = bin_identity
575 interpreter = bin_dir / "python3"
576 target, interpreter_snapshots = _interpreter_chain(interpreter, policy)
577 snapshots.update(interpreter_snapshots)
578 tree_before = _environment_tree_identity(environment, target, policy)
579 probe = _probe_interpreter(interpreter, environment)
580 tree_after = _environment_tree_identity(environment, target, policy)
581 if tree_before != tree_after:
582 _fail("managed environment tree changed while its interpreter was probed")
583 sources = ReceiptSources(environment, pyproject, lockfile, group)
584 payload = _receipt_payload(sources, target, probe, tree_after)
585 _check_snapshots(snapshots)
586 return environment, payload
587
588
589def _read_receipt(
590 environment: Path, policy: TrustPolicy
591) -> tuple[dict[str, Any], Path, ObjectIdentity]:
592 """Read and validate the immutable receipt through a no-follow descriptor."""
593 receipt = environment / RECEIPT_NAME
594 data, identity = _safe_file_bytes(receipt)
595 if identity.uid != policy.environment_uid:
596 _fail(f"managed environment receipt has an untrusted owner: {receipt}")
597 if stat.S_IMODE(identity.mode) != RECEIPT_MODE:
598 _fail(f"managed environment receipt must have mode 0444: {receipt}")
599 try:
600 payload = json.loads(data.decode("ascii"))
601 except (UnicodeDecodeError, json.JSONDecodeError) as error:
602 _fail(f"managed environment receipt is malformed: {error}")
603 if not isinstance(payload, dict):
604 _fail("managed environment receipt must contain one JSON object")
605 return payload, receipt, identity
606
607
608def _verify(
609 environment_raw: str,
610 pyproject: Path,
611 lockfile: Path,
612 group: str,
613 policy: TrustPolicy,
614) -> Path:
615 """Verify the receipt and return the authenticated bin directory."""
616 environment = _canonical_environment(environment_raw)
617 route = _validate_route(environment, policy.route_uids)
618 if route[environment].uid != policy.environment_uid:
619 _fail(f"managed environment is not owned by uid {policy.environment_uid}: {environment}")
620 receipt, receipt_path, receipt_identity = _read_receipt(environment, policy)
621 checked_environment, current = _collect_payload(
622 environment_raw, pyproject, lockfile, group, policy
623 )
624 if set(receipt) != set(current):
625 _fail("managed environment receipt has an unknown or incomplete schema")
626 for key in sorted(current):
627 if receipt.get(key) != current[key]:
628 _fail(f"managed environment receipt mismatch for {key}")
629 if _identity(receipt_path) != receipt_identity:
630 _fail("managed environment receipt changed during verification")
631 if _sha256_file(pyproject) != current["pyproject_sha256"]:
632 _fail("pyproject.toml changed during managed environment verification")
633 if _sha256_file(lockfile) != current["uv_lock_sha256"]:
634 _fail("uv.lock changed during managed environment verification")
635 if _sha256_file(Path(__file__)) != current["authority_sha256"]:
636 _fail("managed environment authority changed during verification")
637 if (
638 _sha256_file(Path(__file__).with_name("managed_python_env_checks.py"))
639 != current["checks_sha256"]
640 ):
641 _fail("managed environment checks helper changed during verification")
642 return checked_environment / "bin"
643
644
645def _authentication_cache_key(
646 environment_raw: str,
647 pyproject: Path,
648 lockfile: Path,
649 group: str,
650 policy: TrustPolicy,
651) -> str:
652 """Key a process-local post-verification cache to every mutable input."""
653 if not re.fullmatch(r"[a-z][a-z0-9-]{0,31}", group):
654 _fail(f"invalid dependency group identity: {group!r}")
655 environment = _canonical_environment(environment_raw)
656 route = _validate_route(environment, policy.route_uids)
657 environment_identity = route[environment]
658 if environment_identity.uid != policy.environment_uid:
659 _fail(f"managed environment is not owned by uid {policy.environment_uid}: {environment}")
660 _payload, receipt, receipt_identity = _read_receipt(environment, policy)
661 material = {
662 "authority_sha256": _sha256_file(Path(__file__)),
663 "checks_sha256": _sha256_file(Path(__file__).with_name("managed_python_env_checks.py")),
664 "dependency_group": group,
665 "environment_identity": environment_identity.cache_fields(),
666 "environment_path": str(environment),
667 "pyproject_sha256": _sha256_file(pyproject),
668 "receipt_identity": receipt_identity.cache_fields(),
669 "receipt_sha256": _sha256_file(receipt),
670 "uv_lock_sha256": _sha256_file(lockfile),
671 }
672 encoded = json.dumps(material, sort_keys=True, separators=(",", ":")).encode("ascii")
673 return hashlib.sha256(encoded).hexdigest()
674
675
676def _atomic_write(environment: Path, payload: dict[str, Any], policy: TrustPolicy) -> None:
677 """Publish a read-only receipt atomically through the environment dirfd."""
678 flags = os.O_RDONLY | os.O_DIRECTORY | getattr(os, "O_CLOEXEC", 0)
679 flags |= getattr(os, "O_NOFOLLOW", 0)
680 directory_fd = os.open(environment, flags)
681 temporary = f".{RECEIPT_NAME}.tmp-{os.getpid()}"
682 receipt_bytes = (
683 json.dumps(payload, sort_keys=True, indent=2, ensure_ascii=True) + "\n"
684 ).encode("ascii")
685 try:
686 file_flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_CLOEXEC", 0)
687 file_flags |= getattr(os, "O_NOFOLLOW", 0)
688 descriptor = os.open(temporary, file_flags, 0o400, dir_fd=directory_fd)
689 try:
690 remaining = memoryview(receipt_bytes)
691 while remaining:
692 written = os.write(descriptor, remaining)
693 if written <= 0:
694 _fail("could not finish the managed-environment receipt write")
695 remaining = remaining[written:]
696 os.fsync(descriptor)
697 os.fchmod(descriptor, RECEIPT_MODE)
698 identity = ObjectIdentity.from_stat(os.fstat(descriptor))
699 if identity.uid != policy.environment_uid:
700 _fail("temporary managed-environment receipt has an untrusted owner")
701 finally:
702 os.close(descriptor)
703 os.replace(
704 temporary,
705 RECEIPT_NAME,
706 src_dir_fd=directory_fd,
707 dst_dir_fd=directory_fd,
708 )
709 os.fsync(directory_fd)
710 finally:
711 with contextlib.suppress(FileNotFoundError):
712 os.unlink(temporary, dir_fd=directory_fd)
713 os.close(directory_fd)
714
715
716def _write(
717 environment_raw: str,
718 pyproject: Path,
719 lockfile: Path,
720 group: str,
721 policy: TrustPolicy,
722) -> Path:
723 """Collect, atomically publish, then re-verify one receipt."""
724 environment, payload = _collect_payload(environment_raw, pyproject, lockfile, group, policy)
725 _atomic_write(environment, payload, policy)
726 return _verify(environment_raw, pyproject, lockfile, group, policy)
727
728
729def _make_test_environment(root: Path, name: str) -> Path:
730 """Create a private stdlib-only venv for authentication selftests."""
731 environment = root / name
732 result = _run_command(
733 [str(Path(sys.executable).resolve()), "-I", "-m", "venv", str(environment)],
734 os.environ.copy(),
735 60,
736 )
737 if result.returncode != 0:
738 _fail(f"selftest could not create a virtual environment: {result.stderr}")
739 return environment
740
741
742def _expect_rejection(label: str, operation: Callable[[], object]) -> None:
743 """Require one hostile selftest operation to fail closed."""
744 try:
745 operation()
746 except ManagedEnvironmentError:
747 return
748 _fail(f"selftest accepted {label}")
749
750
751def _writable_route_selftest(
752 root: Path,
753 environment: Path,
754 pyproject: Path,
755 lockfile: Path,
756 policy: TrustPolicy,
757) -> None:
758 """Exercise writable receipt, bin, environment, and parent rejection."""
759 checks = _load_checks()
760
761 receipt = environment / RECEIPT_NAME
762 receipt.chmod(0o644)
763 _expect_rejection(
764 "a writable receipt",
765 lambda: _verify(str(environment), pyproject, lockfile, "ci", policy),
766 )
767 receipt.chmod(RECEIPT_MODE)
768 (environment / "bin").chmod(0o775)
769 _expect_rejection(
770 "a group-writable bin directory",
771 lambda: _verify(str(environment), pyproject, lockfile, "ci", policy),
772 )
773 (environment / "bin").chmod(0o755)
774 environment.chmod(0o777)
775 _expect_rejection(
776 "a writable environment root",
777 lambda: _verify(str(environment), pyproject, lockfile, "ci", policy),
778 )
779 environment.chmod(0o755)
780
781 interpreter = environment / "bin/python3"
782 original_target = interpreter.readlink()
783 interpreter.unlink()
784 shutil.copy2(Path(sys.executable).resolve(), interpreter)
785 interpreter.chmod(0o775)
786 _expect_rejection(
787 "a group-writable interpreter target",
788 lambda: _verify(str(environment), pyproject, lockfile, "ci", policy),
789 )
790 interpreter.unlink()
791 interpreter.symlink_to(original_target)
792
793 checks.rewrite_receipt(environment, {"python_version": "3.99.0"}, RECEIPT_NAME, RECEIPT_MODE)
794 _expect_rejection(
795 "the wrong Python version",
796 lambda: _verify(str(environment), pyproject, lockfile, "ci", policy),
797 )
798 checks.rewrite_receipt(environment, {"checks_sha256": "0" * 64}, RECEIPT_NAME, RECEIPT_MODE)
799 _expect_rejection(
800 "the wrong checks-helper digest",
801 lambda: _verify(str(environment), pyproject, lockfile, "ci", policy),
802 )
803 _write(str(environment), pyproject, lockfile, "ci", policy)
804 root.chmod(0o777)
805 _expect_rejection(
806 "a writable parent route",
807 lambda: _verify(str(environment), pyproject, lockfile, "ci", policy),
808 )
809 root.chmod(0o700)
810
811
812def _filesystem_selftest(root: Path, policy: TrustPolicy) -> None:
813 """Exercise valid, stale, copied, writable, and symlinked environments."""
814 checks = _load_checks()
815
816 pyproject = root / "pyproject.toml"
817 lockfile = root / "uv.lock"
818 pyproject.write_text("[dependency-groups]\nci=[]\n", encoding="ascii")
819 lockfile.write_text("version = 1\n", encoding="ascii")
820 environment = _make_test_environment(root, "managed")
821 expected_bin = _write(str(environment), pyproject, lockfile, "ci", policy)
822 if expected_bin != environment / "bin":
823 _fail("selftest did not return the authenticated bin directory")
824 _verify(str(environment), pyproject, lockfile, "ci", policy)
825 harness = checks.FilesystemHarness(
826 refresh=lambda: _write(str(environment), pyproject, lockfile, "ci", policy),
827 reject_environment=lambda label, candidate, group: _expect_rejection(
828 label,
829 lambda: _verify(str(candidate), pyproject, lockfile, group, policy),
830 ),
831 make_environment=lambda name: _make_test_environment(root, name),
832 cache_key=lambda group: _authentication_cache_key(
833 str(environment), pyproject, lockfile, group, policy
834 ),
835 fail=_fail,
836 receipt_name=RECEIPT_NAME,
837 receipt_mode=RECEIPT_MODE,
838 )
839 checks.cache_key_selftest(environment, pyproject, lockfile, harness)
840 checks.stale_receipt_selftest(root, environment, pyproject, lockfile, harness)
841 _writable_route_selftest(root, environment, pyproject, lockfile, policy)
842 checks.nested_tree_selftest(environment, harness)
843
844
845def _selftest() -> int:
846 """Run non-vacuous positive and hostile receipt/consumer tests."""
847 checks = _load_checks()
848
849 home = Path.home().resolve()
850 if _identity(home).mode & 0o022:
851 _fail(f"selftest home route is group/other writable: {home}")
852 root = Path(tempfile.mkdtemp(prefix=".ra8-managed-env-", dir=home))
853 root.chmod(0o700)
854 try:
855 _filesystem_selftest(root, TrustPolicy.selftest())
856 finally:
857 root.chmod(0o700)
858 shutil.rmtree(root)
859 failures = checks.consumer_contract_selftest()
860 if failures:
861 _fail("; ".join(failures))
862 print("managed_python_env.py --selftest: PASS")
863 return 0
864
865
866def _parser() -> argparse.ArgumentParser:
867 """Build the command-line parser without exposing trust-policy overrides."""
868 parser = argparse.ArgumentParser(description=__doc__)
869 subparsers = parser.add_subparsers(dest="command", required=True)
870 for command in ("write", "verify", "cache-key"):
871 child = subparsers.add_parser(command)
872 child.add_argument("--env", required=True)
873 child.add_argument("--pyproject", required=True, type=Path)
874 child.add_argument("--lock", required=True, type=Path)
875 child.add_argument("--group", required=True)
876 if command == "verify":
877 child.add_argument("--print-bin", action="store_true")
878 check = subparsers.add_parser("check-consumers")
879 check.add_argument("--root", type=Path, default=Path.cwd())
880 return parser
881
882
883def main(argv: list[str] | None = None) -> int:
884 """Dispatch receipt creation, authentication, and contract checks."""
885 if argv is None:
886 argv = sys.argv[1:]
887 if argv == ["--selftest"]:
888 return _selftest()
889 arguments = _parser().parse_args(argv)
890 if arguments.command == "check-consumers":
891 checks = _load_checks()
892 root = arguments.root.resolve()
893 findings = checks.consumer_findings(root) + checks.consumer_runtime_findings(
894 root, _run_command
895 )
896 for finding in findings:
897 print(f"managed-python-env: {finding}", file=sys.stderr)
898 if findings:
899 return 1
900 print("managed Python environment consumers share one authenticated authority")
901 return 0
902 policy = TrustPolicy.production()
903 if arguments.command == "write":
904 if os.geteuid() != 0:
905 _fail("only root may create a production managed-environment receipt")
906 bin_dir = _write(
907 arguments.env, arguments.pyproject, arguments.lock, arguments.group, policy
908 )
909 print(f"wrote authenticated managed Python environment receipt for {bin_dir.parent}")
910 return 0
911 if arguments.command == "cache-key":
912 print(
913 _authentication_cache_key(
914 arguments.env,
915 arguments.pyproject,
916 arguments.lock,
917 arguments.group,
918 policy,
919 )
920 )
921 return 0
922 bin_dir = _verify(arguments.env, arguments.pyproject, arguments.lock, arguments.group, policy)
923 message = (
924 str(bin_dir)
925 if arguments.print_bin
926 else f"authenticated managed Python environment: {bin_dir.parent}"
927 )
928 print(message)
929 return 0
930
931
932if __name__ == "__main__":
933 try:
934 raise SystemExit(main())
935 except ManagedEnvironmentError as error:
936 print(f"managed_python_env.py: FATAL: {error}", file=sys.stderr)
937 raise SystemExit(1) from None
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298
#define min(x, y)
Untyped minimum shim used by the SOUP's buffer clamping.
Definition xz_config.h:157