swayfx-mosaic/tooling/probes/step20c-egl-fx/run.py

243 lines
11 KiB
Python

#!/usr/bin/env python3
"""Sonde spécifique STEP20C : plan passif, puis au plus une tentative approuvée."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import selectors
import signal
import stat
import subprocess
import sys
import time
LIMIT = 1024 * 1024
ENV = {
"PATH": "/usr/bin", "PWD": "/tmp", "LC_ALL": "C",
"WLR_RENDERER_FORCE_SOFTWARE": "1", "WLR_RENDERER_ALLOW_SOFTWARE": "1",
"LIBGL_ALWAYS_SOFTWARE": "1", "LP_NUM_THREADS": "2",
"__EGL_VENDOR_LIBRARY_FILENAMES": "/usr/share/glvnd/egl_vendor.d/50_mesa.json",
}
HOST_ENV = {"PATH": "/usr/bin", "LC_ALL": "C"}
class Refusal(Exception):
"""Le contrat scellé n'est pas satisfait ; ne pas franchir la frontière native."""
def digest(path):
with open(path, "rb") as stream:
return hashlib.file_digest(stream, "sha256").hexdigest()
def command(lab):
"""Liste fermée : aucun mount, argument ou environnement fourni par l'appelant."""
argv = [
"/usr/bin/prlimit", "--as=1073741824", "--cpu=5", "--nofile=64",
"--fsize=1048576", "--core=0", "--nproc=256", "--",
"/usr/bin/bwrap", "--unshare-user", "--unshare-all", "--disable-userns",
"--new-session", "--die-with-parent", "--clearenv", "--ro-bind", "/usr", "/usr",
"--symlink", "usr/bin", "/bin", "--symlink", "usr/lib", "/lib",
"--symlink", "usr/lib", "/lib64", "--proc", "/proc", "--dev", "/dev",
"--tmpfs", "/dev/shm", "--tmpfs", "/tmp", "--tmpfs", "/run",
"--ro-bind", str(lab / "bin/probe"), "/probe", "--chdir", "/tmp",
]
for key, value in ENV.items():
argv.extend(["--setenv", key, value])
return argv + ["--", "/probe"]
def verify(lab, expected):
"""Lecture seule, y compris pour le plan ; le digest doit venir de la revue."""
if dict(os.environ) != HOST_ENV:
raise Refusal("environnement superviseur non conforme ; utiliser env -i")
if not lab.is_absolute() or lab.resolve() != lab:
raise Refusal("LAB absolu sans symlink requis")
info = lab.stat()
if info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) != 0o700:
raise Refusal("LAB privé mode0700 requis")
manifest_path = lab / "manifest.json"
if digest(manifest_path) != expected:
raise Refusal("digest du manifeste différent de l'approbation")
manifest = json.loads(manifest_path.read_text())
if manifest["schema"] != 1 or manifest["lab"] != str(lab):
raise Refusal("identité du manifeste")
if manifest["argv"] != command(lab) or manifest["environment"] != ENV:
raise Refusal("montages, devices ou environnement altérés")
if manifest["supervisor_environment"] != HOST_ENV:
raise Refusal("environnement du superviseur altéré")
required = {str(lab / "bin/probe"), str(lab / "fixture/run.py"),
"/usr/bin/bwrap", "/usr/bin/prlimit"}
if not required.issubset(manifest["files"]):
raise Refusal("identités requises absentes")
if Path(__file__).resolve() != lab / "fixture/run.py":
raise Refusal("utiliser le lanceur scellé du LAB")
for name, identity in manifest["files"].items():
path = Path(name)
if not (path.is_relative_to(lab) or path.is_relative_to("/usr")):
raise Refusal("dépendance hors LAB neuf ou /usr")
if digest(path) != identity["sha256"]:
raise Refusal(f"dérive identité : {path}")
if str(path.resolve()) != identity["resolved"]:
raise Refusal(f"dérive symlink : {path}")
return manifest
def durable(path, data, exclusive=False):
"""Reçu borné et durable ; O_EXCL rend le budget irréversible avant spawn."""
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
destination = path if exclusive else path.with_suffix(path.suffix + ".tmp")
fd = os.open(destination, flags, 0o600)
try:
with os.fdopen(fd, "wb") as stream:
stream.write(data)
stream.flush()
os.fsync(stream.fileno())
if not exclusive:
os.replace(destination, path)
directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(directory)
finally:
os.close(directory)
except BaseException:
# A partial ATTEMPT still consumes the budget; do not remove it.
raise
def supervise(argv, payload_seconds=8.0, total_seconds=10.0):
"""Deux pipes bornés, collecte du seul groupe possédé, aucun retry.
bwrap conserve le PID namespace jusqu'à la fin du payload ; sa disparition
termine les descendants de ce namespace. Pas de kill d'un PID déjà récolté.
Les limites AS/CPU sont par processus ; NPROC porte sur l'UID, pas un cgroup.
"""
started = time.monotonic()
channels = [bytearray(), bytearray()]
cause = "exit"
diagnostic = None
process = subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, env=HOST_ENV, close_fds=True,
start_new_session=True)
pidfd = None
try:
if hasattr(os, "pidfd_open"):
try:
pidfd = os.pidfd_open(process.pid)
except OSError:
# Popen remains unreaped and owns this PID during cleanup.
pidfd = None
with selectors.DefaultSelector() as selector:
for index, stream in enumerate((process.stdout, process.stderr)):
os.set_blocking(stream.fileno(), False)
selector.register(stream, selectors.EVENT_READ, index)
killed = False
while selector.get_map():
elapsed = time.monotonic() - started
if not killed and elapsed >= payload_seconds:
cause = "timeout"
if not killed and cause != "exit":
# Do not poll/reap before killpg: an unreaped child prevents
# PID reuse, even if the child exited between these steps.
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass # Already exited, still explicitly collected below.
killed = True
if elapsed >= total_seconds:
cause = "cleanup_unproven"
break
for key, _ in selector.select(min(0.05, total_seconds - elapsed)):
chunk = os.read(key.fileobj.fileno(), 65536)
if not chunk:
selector.unregister(key.fileobj)
continue
target = channels[key.data]
room = LIMIT - len(target)
target.extend(chunk[:room])
if len(chunk) > room and cause == "exit":
cause = "output_overflow"
elapsed = time.monotonic() - started
wait_budget = total_seconds if killed else payload_seconds
try:
code = process.wait(timeout=max(0.001, wait_budget - elapsed))
except subprocess.TimeoutExpired:
# Pipes can close while the leader still runs. Preserve the
# cleanup reserve instead of spending it waiting for payload.
if cause == "exit":
cause = "timeout"
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
try:
code = process.wait(timeout=max(0.001, total_seconds -
(time.monotonic() - started)))
except subprocess.TimeoutExpired:
code = None
cause = "cleanup_unproven"
except BaseException as error:
# Popen has not been reaped on exceptional drain paths. Never abandon
# a child because selector/read/collection itself failed.
cause = "supervisor_failure"
diagnostic = f"{type(error).__name__}: {error}"[:512]
code = process.returncode
if code is None:
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
try:
code = process.wait(timeout=max(0.001, total_seconds -
(time.monotonic() - started)))
except subprocess.TimeoutExpired:
code = None
cause = "cleanup_unproven"
if not isinstance(error, Exception):
# Interruption is recorded after cleanup, not allowed to lose the
# irreversible attempt or raw evidence.
cause = "interrupted" if code is not None else "cleanup_unproven"
finally:
process.stdout.close()
process.stderr.close()
if pidfd is not None:
os.close(pidfd)
return {"cause": cause, "returncode": code, "error": diagnostic,
"collected": code is not None, "elapsed": time.monotonic() - started}, channels
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--lab", required=True, type=Path)
parser.add_argument("--manifest-sha256", required=True)
parser.add_argument("--approve-native-probe", action="store_true")
args = parser.parse_args()
try:
manifest = verify(args.lab, args.manifest_sha256)
if (args.lab / "ATTEMPT").exists():
raise Refusal("budget déjà consommé ; aucun retry")
if not args.approve_native_probe:
print(json.dumps({"runtime": "NOT_RUN", "budget": 1,
"argv": manifest["argv"], "environment": ENV}, indent=2))
return 0
durable(args.lab / "ATTEMPT", json.dumps({
"manifest_sha256": args.manifest_sha256, "budget_consumed": 1,
}).encode() + b"\n", exclusive=True)
try:
result, channels = supervise(manifest["argv"])
except OSError as error:
result = {"cause": "spawn_failure", "error": str(error), "collected": True}
channels = [b"", b""]
for name, data in zip(("stdout.raw", "stderr.raw"), channels):
durable(args.lab / name, data)
durable(args.lab / "result.json", json.dumps(result, sort_keys=True).encode() + b"\n")
print(json.dumps(result))
return 0 if result.get("cause") == "exit" and result.get("returncode") == 0 else 20
except (Refusal, OSError, ValueError, KeyError, TypeError) as error:
print(f"Refus : {error}", file=sys.stderr)
return 20
if __name__ == "__main__":
sys.exit(main())