243 lines
11 KiB
Python
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())
|