From 99f1fbeb4010c7f3ff82c97138431a3c0c5c387c Mon Sep 17 00:00:00 2001 From: fy59 Date: Sat, 17 Oct 2026 07:07:37 +0200 Subject: [PATCH] test(mosaic): prepare isolated EGL FX probe without native execution --- docs/README.md | 5 + docs/design/STEP20C_HEADLESS_EGL_FX_PROBE.md | 140 ++++++++++ tooling/probes/step20c-egl-fx/README.md | 109 ++++++++ tooling/probes/step20c-egl-fx/prepare.py | 198 ++++++++++++++ tooling/probes/step20c-egl-fx/probe.c | 253 ++++++++++++++++++ tooling/probes/step20c-egl-fx/run.py | 243 +++++++++++++++++ tooling/probes/step20c-egl-fx/test_probe.py | 225 ++++++++++++++++ .../probes/step20c-egl-fx/test_probe_mocks.c | 158 +++++++++++ 8 files changed, 1331 insertions(+) create mode 100644 docs/design/STEP20C_HEADLESS_EGL_FX_PROBE.md create mode 100644 tooling/probes/step20c-egl-fx/README.md create mode 100644 tooling/probes/step20c-egl-fx/prepare.py create mode 100644 tooling/probes/step20c-egl-fx/probe.c create mode 100644 tooling/probes/step20c-egl-fx/run.py create mode 100644 tooling/probes/step20c-egl-fx/test_probe.py create mode 100644 tooling/probes/step20c-egl-fx/test_probe_mocks.c diff --git a/docs/README.md b/docs/README.md index d72c7bb..b7c96c5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,5 +1,10 @@ # Documentation +[Isolated EGL/FX probe preparation](design/STEP20C_HEADLESS_EGL_FX_PROBE.md) +is EXPERIMENTAL / EGL_FX_PROBE_READY_FOR_APPROVAL; its source-only fixture and +reported offline tests await independent validation. Runtime NOT_RUN; separate +conversational approval is required for the single native measurement. + [Gate 2 headless boot](design/STEP20C_HEADLESS_BOOT_GATE2.md) records the single V5 native failure: EXPERIMENTAL / PRISTINE_ALLOCATOR_BLOCKED / CORE_NOT_RUN, without private IPC or rendering proof. The [allocator triage](design/STEP20C_HEADLESS_ALLOCATOR_TRIAGE.md) diff --git a/docs/design/STEP20C_HEADLESS_EGL_FX_PROBE.md b/docs/design/STEP20C_HEADLESS_EGL_FX_PROBE.md new file mode 100644 index 0000000..6ff9c89 --- /dev/null +++ b/docs/design/STEP20C_HEADLESS_EGL_FX_PROBE.md @@ -0,0 +1,140 @@ +# STEP20C — isolated EGL/FX probe preparation + +Status: **EXPERIMENTAL / EGL_FX_PROBE_READY_FOR_APPROVAL / runtime NOT_RUN**. +The source-only fixture prepares one future measurement, without authorizing execution. +STEP20C remains NEXT; production multi-output NOT_YET_SUPPORTED. +The [Gate 2 V5 failure](STEP20C_HEADLESS_BOOT_GATE2.md) remains +PRISTINE_ALLOCATOR_BLOCKED / CORE_NOT_RUN; V4 failed before compositor execution. +Historical V4/V5 LABs and verdicts are immutable. + +## Measurement contract + +Use real pinned SceneFX/wlroots objects without sway_server, compositor, +Wayland display/socket, client, IPC or output capture. A headless backend needs +only its own event loop; it need not be started or create outputs. Inspect +software EGL separately from compatibility of FX render targets. No physical +host device, seat, desktop socket, bus or host network may be exposed. + +| Quantity | Prepared observation / explicit limit | +| --- | --- | +| EGL vendor, version, display extensions | Query the actual display owned by the FX path; do not infer from installed metadata | +| Selected software EGL device | Query the device associated with that display and require EGL_MESA_device_software; missing identity is refusal | +| DRM FD | Both backend and renderer getters must return exactly -1; otherwise refusal, no hardware fallback | +| Backend and renderer target caps | Read real headless buffer_caps and renderer render_buffer_caps | +| Texture support | Query format sets separately for DMABUF, DATA_PTR and SHM; no invented texture_caps field | +| Target formats | Query the real FX render format set, distinct from texture formats | +| Target allocation | Always NOT_ATTEMPTED; report pinned-source eligibility separately from allocation or rendering | + +Only source-backed, existing interfaces may be used. Internal interfaces must +be identified as such and bound to the exact pinned pair; no public ABI change +or invented getter is permitted. The reviewed choice is explicit +`wlr_egl_create_with_drm_fd(-1)` followed by `fx_renderer_create_egl(egl)`. +These are the real operations of SceneFX's software creation path, accessed +through the exact internal headers, without reading either private struct. +This measures those constructors; it does not exercise the public +fx_renderer_create(backend) entrypoint or its backend-selection policy. + +Require EGL_EXT_device_query or EGL_EXT_device_base and query EGL_DEVICE_EXT from the +created display before FX creation. Require EGL_MESA_device_software and reject +DRM device extensions before FX initialization: its DRM getter can otherwise +attempt to open a render node. The selected device identity is borrowed until +EGL destruction, as specified by +[EGL_EXT_device_query](https://registry.khronos.org/EGL/extensions/EXT/EGL_EXT_device_query.txt). Before FX construction succeeds the helper owns EGL; after +success the renderer owns it. Destroy renderer (or untransferred EGL), then +backend, then event loop. Format sets are borrowed until renderer destruction. +The declared wlr_fx_renderer_get_egl has no export and must not be used. + +No allocator is called: eligibility from real caps plus pinned source is +reported separately from target allocation, which is NOT_ATTEMPTED. Without +UDMABUF and with DMABUF-only FX targets/FD -1, no pinned allocator route is +compatible under this confinement. No general impossibility theorem is claimed. +FX construction initializes its own internal resources; the helper calls no +buffer/framebuffer creation or draw command. No invented texture_caps field is +used. JSONL records EGL identity before FX construction, then formats, caps, +FDs and completion. Strings are bounded to 16384 bytes, sets to 256 formats, +each format to 64 modifiers, and the four sets to 4096 modifiers total. +Missing identity or overflow refuses rather than truncates. Exit 0 means only +completed measurement; exit 20 means refused/incomplete. Inaccessible required +ABI or a mandatory host device blocks preparation instead of weakening isolation. + +## Source-only preparation and identity boundary + +The [six-file fixture](../../tooling/probes/step20c-egl-fx/README.md) contains +probe.c, prepare.py, run.py, test_probe.py, test_probe_mocks.c and README.md. +Its README gives exact preparation and inert-test commands. prepare.py creates +a new private mode0700 LAB, extracts SceneFX and wlroots from pinned Git +objects, checks every extracted blob, compares compiled installed headers and +copies only generated wlroots config/version headers. The pins and public +source URLs are recorded in the [allocator triage](STEP20C_HEADLESS_ALLOCATOR_TRIAGE.md). +Compilation uses C17 syntax checking and linking, SceneFX before wlroots; +neither the target loader, ldd nor the real helper is executed. + +The private manifest seals argv, environments, compile commands, cc -M inputs, +source/header/script/tool/helper hashes, ELF identities and DT_NEEDED closure. +The prepared manifest covers 755 files, 80 ELF identities, 17 compared installed +headers and 557 source blobs. Installed ELF bytes/Build IDs are pinned, not +reproduced from source; header/export agreement proves no binary source identity. +Mesa EGL, swrast, GBM dri and gallium providers are explicit closure seeds; +conditional runtime dlopen remains unobserved. The inspected ELF closure has +no RPATH/RUNPATH; prepare.py does not enforce a general RPATH rejection policy. +Licences remain in private source archives; no binary, raw log or private +manifest belongs in Git. + +## Fixed confinement and unconsumed budget + +run.command fixes prlimit then Bubblewrap with --unshare-user --unshare-all +--disable-userns --new-session --die-with-parent --clearenv, read-only /usr, +/bin → usr/bin and /lib,/lib64 → usr/lib, private /proc and /dev, tmpfs +/dev/shm, /tmp and /run, and only the new helper bound read-only at /probe. +The working directory is /tmp. No host HOME, /etc, /sys, /run/user, /dev/dri, +/dev/input, /dev/udmabuf, seat, desktop socket, bus or host network is exposed. +The supervisor environment is exactly PATH=/usr/bin and LC_ALL=C; the helper +adds PWD=/tmp, WLR_RENDERER_FORCE_SOFTWARE=1, WLR_RENDERER_ALLOW_SOFTWARE=1, +LIBGL_ALWAYS_SOFTWARE=1, LP_NUM_THREADS=2 and +__EGL_VENDOR_LIBRARY_FILENAMES=/usr/share/glvnd/egl_vendor.d/50_mesa.json. +This selects the Mesa ICD; software identity still requires the associated-device +query. No caller-supplied mount/environment or automatic alternative ICD is allowed. + +Budget: one launch, no retry; 8 s payload plus 2 s cleanup reserve within a +nominal supervised 10 s total; 1 MiB each raw stdout/stderr; AS 1 GiB, CPU 5 s, +NOFILE 64, FSIZE 1 MiB, CORE 0 and NPROC 256. AS/CPU apply per process; NPROC +applies to the global UID, not an aggregate cgroup. The supervisor closes +inherited FDs, drains pipes concurrently, uses pidfd when available and cleans +only its own unreaped leader/process group; bwrap PID namespace exit terminates +its descendants. Unproven collection is failure. This provides no hostile +same-UID-peer guarantee. Receipts/raw channels stay bounded and private, with +atomic writes/fsync. No systemd scope, compositor, client, socket or capture. + +## Preparation and approval boundary + +Offline evidence: 23 Python tests and eight inert C-mock scenarios passed; +the real helper passed C17 syntax checking and linking without execution. +The C harness replaces all EGL/Wayland/wlroots calls and links only libc. +Independent validation confirms the sealed target manifest, all 755 file +identities, fixture/source correspondence, passive plan, C17 syntax and inert +C tests. Python tests were also independently exercised on identical fixtures +in a separate preparation; its different path-dependent manifest is not drift +of the target LAB. Historical V5's 101 checked files are unchanged. +The unique independent code/protocol review is PASS, with no BLOCKING or +NON_BLOCKING defect. Final preparation verdict: +**EGL_FX_PROBE_READY_FOR_APPROVAL / runtime NOT_RUN**. +No ATTEMPT exists and the single-launch budget remains unconsumed. This verdict +qualifies preparation only; software EGL/FX success remains unmeasured. + +The default run.py entrypoint only verifies and prints a passive plan: no files +created, Bubblewrap or ELF launched. Copy the manifest digest from the reviewed +preparation; do not recompute it to accept drift. A future command has this shape: + +```sh +/usr/bin/env -i PATH=/usr/bin LC_ALL=C /usr/bin/python3 -I \ + /var/tmp/NEW_PRIVATE_LAB/fixture/run.py \ + --lab /var/tmp/NEW_PRIVATE_LAB --manifest-sha256 REVIEWED_DIGEST \ + --approve-native-probe +``` + +This is **not authorization**: a separate conversational approval is required. +The flag durably consumes ATTEMPT before spawn; any failure, including spawn +failure, forbids retry. Existing ATTEMPT also refuses the passive plan. +Invoking the real helper, even --version, remains forbidden in this preparation. +No device access, compositor, scope, live IPC, installation or production ABI +change is authorized. Runtime remains NOT_RUN. diff --git a/tooling/probes/step20c-egl-fx/README.md b/tooling/probes/step20c-egl-fx/README.md new file mode 100644 index 0000000..f908842 --- /dev/null +++ b/tooling/probes/step20c-egl-fx/README.md @@ -0,0 +1,109 @@ +# STEP20C — fixture source-only EGL/FX + +**EXPERIMENTAL / runtime NOT_RUN.** Cette fixture prépare une unique mesure +future. Elle n'ajoute aucune dépendance de production, ne démarre aucun +compositeur et ne démontre ni allocation ni rendu. Le document de référence est +[la proposition de sonde](../../../docs/design/STEP20C_HEADLESS_EGL_FX_PROBE.md). +Les anciens LAB restent en lecture seule. + +`probe.c` crée seulement un event loop, un backend headless non démarré et EGL +via le constructeur interne pinned `wlr_egl_create_with_drm_fd(-1)`. Il interroge +**le device associé à ce display**, exige `EGL_MESA_device_software` et refuse +les extensions DRM avant `fx_renderer_create_egl`. Le constructeur FX initialise +ses ressources internes ; la sonde n'appelle ni allocateur, ni création de +buffer/framebuffer, ni commande de dessin. Les formats de texture DMABUF, +DATA_PTR et SHM sont distincts des formats cibles. Les sets et FDs sont +empruntés ; EGL est détruit par le renderer seulement après transfert réussi. + +Les chaînes sont limitées à 16384 octets, chaque set à 256 formats, chaque +format à 64 modifiers et leur total à 4096. Les identités absentes ou un +dépassement refusent la mesure ; aucune troncature n'est qualifiée de succès. +Sorties JSONL, étape EGL flushée avant FX, statut 0 pour une mesure terminée, +20 pour un refus ou une mesure incomplète. `target_allocation=NOT_ATTEMPTED` +reste vrai ; l'éligibilité conditionnelle des allocateurs pinned est une +interprétation de capacités, pas une allocation mesurée. + +Préparation passive, sans exécuter le helper, dans un **nouveau** LAB privé : + +```sh +python3 tooling/probes/step20c-egl-fx/prepare.py \ + --lab /var/tmp/NOUVEAU_LAB_PRIVE \ + --source-store /var/tmp/swayfx-mosaic-corec-v2-i516p6po/sources +``` + +Le script lit les objets git pinned SceneFX +`37ccd723bef49e6891156ffafce8f549f01446cc` et wlroots +`d783533489e1f75d6886c2ab5c5960090ef268f8`, extrait leurs archives dans le +nouveau LAB et vérifie chaque blob. Il copie les seuls headers générés de +l'installation et compare les headers publics compilés aux sources pinned. +Le contrôle syntaxique utilise C17 avant le link, SceneFX avant wlroots. +Le manifeste contient les commandes exactes, les dépendances `cc -M`, les +SHA256 des sources/headers/scripts/helper/outils, les identités ELF et la +fermeture `readelf` DT_NEEDED. Aucun `ldd`, loader ou helper n'est exécuté. +Les bibliothèques installées sont scellées par leurs bytes et Build IDs ; elles +ne sont **pas reproduites depuis les sources**, et la correspondance des +headers/exportations ne prouve pas leur provenance source. Les licences restent +dans les archives privées ; aucun binaire, log ou manifeste privé n'entre dans Git. + +Les tests Python ne lancent que des processus Python inertes pour exercer les +pipes, le timeout et la collecte. Aucun test n'invoque bwrap ou le helper : + +```sh +PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover \ + -s tooling/probes/step20c-egl-fx -p test_probe.py -v +``` + +Le harnais C substitue **tous** les appels EGL/Wayland/wlroots et inclut le +corps réel de `probe.c`. Ses huit scénarios discriminent le device sélectionné, +les extensions DRM, le transfert EGL, l'ordre de destruction et les formats. +Il lie uniquement libc, sans bibliothèque EGL/FX ; son exécution reste un test +inerte, distinct de l'exécution interdite du helper réel. Après préparation : + +```sh +LAB=/var/tmp/NOUVEAU_LAB_PRIVE +cc -std=c17 -Wall -Wextra -Werror -DWLR_USE_UNSTABLE \ + -I"$LAB/generated" -I"$LAB/sources/scenefx/include" \ + -I"$LAB/sources/wlroots/include" \ + $(pkg-config --cflags scenefx-0.5 wlroots-0.20 wayland-server egl glesv2 pixman-1 libdrm) \ + tooling/probes/step20c-egl-fx/test_probe_mocks.c -o "$LAB/bin/inert-c-tests" +readelf -dW "$LAB/bin/inert-c-tests" +"$LAB/bin/inert-c-tests" +``` + +Le préparateur imprime le digest du manifeste et la commande exacte du **plan +passif**. Recopier ce digest depuis la préparation revue, sans le recalculer +au moment d'exécuter. Le plan ne crée aucun fichier et n'exécute aucun ELF. Un LAB dont `ATTEMPT` +existe refuse aussi le plan, car son budget est déjà consommé. +Après une **nouvelle autorisation conversationnelle**, la même commande avec +`--approve-native-probe` consomme durablement `ATTEMPT` avant spawn ; toute +erreur, tout timeout et même un échec de spawn interdisent un retry : + +```sh +/usr/bin/env -i PATH=/usr/bin LC_ALL=C /usr/bin/python3 -I \ + /var/tmp/NOUVEAU_LAB_PRIVE/fixture/run.py \ + --lab /var/tmp/NOUVEAU_LAB_PRIVE --manifest-sha256 DIGEST_REVU \ + --approve-native-probe +``` + +Le lanceur refuse tout argument de mount/env libre et tout drift des identités. +Le protocole bwrap exact est construit dans `run.command` et scellé. `/usr` est +RO ; seul le helper neuf est lié RO à `/probe`. `/proc`, `/dev`, `/dev/shm`, +`/tmp` et `/run` sont privés ; pas de HOME, `/etc`, `/sys`, réseau hôte, +socket/bus/session, `/dev/dri`, `/dev/input`, `/dev/udmabuf` ou FD hérité. +`PWD=/tmp` est explicite. L'ICD est restreint à Mesa par son JSON sous `/usr` ; +ce choix ne prouve pas l'identité logicielle, vérifiée séparément par EGL. +Mesa EGL, swrast, GBM dri et gallium plus leurs DT_NEEDED sont scellés ; des +chargements conditionnels runtime restent non observés jusqu'à l'unique essai. + +Le budget est exactement un lancement, 8 s de payload et 2 s de réserve de +nettoyage dans un délai nominal supervisé de 10 s au total, 1 MiB par canal brut, AS 1 GiB, CPU 5 s, +NOFILE 64, FSIZE 1 MiB, CORE 0, NPROC 256. AS/CPU sont par processus et NPROC +s'applique à l'UID global ; ce n'est pas une limite agrégée cgroup. Le +superviseur crée sa session, ferme les FDs, lit les deux pipes concurremment, +utilise pidfd si disponible, tue seulement son groupe non récolté et récolte +son enfant. La fin du namespace PID bwrap termine ses descendants. Une +collecte non prouvée est un échec explicite ; aucun PID déjà récolté n'est tué. +Ce contrat ne prétend pas durcir le système contre un pair hostile du même UID, +ni retirer les capacités de diagnostic `/proc` déjà imposées au protocole. +Les canaux bruts et reçus bornés restent privés, avec écritures atomiques et +fsync ; aucun scope systemd n'est créé. diff --git a/tooling/probes/step20c-egl-fx/prepare.py b/tooling/probes/step20c-egl-fx/prepare.py new file mode 100644 index 0000000..11c125f --- /dev/null +++ b/tooling/probes/step20c-egl-fx/prepare.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +"""Préparation passive STEP20C. Compile/inspecte, n'exécute jamais le helper.""" +import argparse +import hashlib +import io +import json +import os +from pathlib import Path +import re +import shlex +import shutil +import subprocess +import tarfile + +from run import ENV, HOST_ENV, command, digest, durable + +PINS = { + "scenefx": "37ccd723bef49e6891156ffafce8f549f01446cc", + "wlroots": "d783533489e1f75d6886c2ab5c5960090ef268f8", +} +FIXTURE = Path(__file__).resolve().parent + + +def capture(argv): + return subprocess.check_output(argv, env=HOST_ENV, stderr=subprocess.STDOUT) + + +def archive_sources(bare, pin, destination): + """git archive lit seulement le dépôt historique ; vérifier chaque blob extrait.""" + actual = capture(["/usr/bin/git", "--git-dir", str(bare), "rev-parse", pin + "^{commit}"]).decode().strip() + if actual != pin: + raise ValueError("commit différent du pin") + archive = capture(["/usr/bin/git", "--git-dir", str(bare), "archive", pin]) + destination.mkdir() + with tarfile.open(fileobj=io.BytesIO(archive)) as stream: + stream.extractall(destination, filter="data") + tree = capture(["/usr/bin/git", "--git-dir", str(bare), "ls-tree", "-rz", pin]) + count = 0 + for record in tree.split(b"\0"): + if not record: + continue + metadata, raw_name = record.split(b"\t", 1) + mode, kind, oid = metadata.split() + if kind != b"blob": + raise ValueError("type git inattendu") + path = destination / os.fsdecode(raw_name) + content = os.readlink(path).encode() if mode == b"120000" else path.read_bytes() + identity = hashlib.sha1(b"blob " + str(len(content)).encode() + b"\0" + content).hexdigest() + if identity != oid.decode(): + raise ValueError(f"blob différent : {path}") + count += 1 + return {"commit": pin, "archive_sha256": hashlib.sha256(archive).hexdigest(), + "verified_blobs": count} + + +def resolve_library(name): + if "/" in name: + path = Path(name) + if path.is_absolute() and path.is_relative_to("/usr") and path.exists(): + return path + raise ValueError(f"bibliothèque hors /usr : {name}") + path = Path("/usr/lib") / name + if not path.is_file(): + raise ValueError(f"dépendance ELF introuvable : {name}") + return path + + +def elf_closure(seeds): + """Fermeture DT_NEEDED passive ; aucune utilisation de ldd/loader.""" + result = {} + pending = list(seeds) + seen = set() + while pending: + path = pending.pop() + if str(path) in result: + continue + data = capture(["/usr/bin/readelf", "-dW", str(path)]).decode() + notes = capture(["/usr/bin/readelf", "-nW", str(path)]).decode() + needed = re.findall(r"\(NEEDED\).*\[([^]]+)\]", data) + build_id = re.search(r"Build ID: ([0-9a-f]+)", notes) + result[str(path)] = {"needed": needed, + "build_id": build_id.group(1) if build_id else None} + canonical = path.resolve() + if canonical not in seen: + seen.add(canonical) + pending.extend(resolve_library(name) for name in needed) + return result + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--lab", type=Path, required=True) + parser.add_argument("--source-store", type=Path, required=True) + args = parser.parse_args() + lab = args.lab + if not lab.is_absolute() or lab.resolve() != lab: + raise ValueError("LAB absolu sans symlink requis") + if lab.exists(): + if lab.stat().st_uid != os.getuid() or lab.stat().st_mode & 0o777 != 0o700: + raise ValueError("LAB privé mode0700 requis") + if any(path.name != "evidence" for path in lab.iterdir()): + raise ValueError("LAB déjà préparé ; aucune réutilisation") + else: + lab.mkdir(mode=0o700) + for name in ("fixture", "sources", "generated", "bin", "evidence"): + (lab / name).mkdir(exist_ok=True) + for name in ("probe.c", "run.py", "prepare.py", "test_probe.py", "test_probe_mocks.c", "README.md"): + shutil.copyfile(FIXTURE / name, lab / "fixture" / name) + provenance = {} + for name, pin in PINS.items(): + provenance[name] = archive_sources(args.source_store / (name + ".git"), pin, + lab / "sources" / name) + # Only generated config/version headers come from the installed build. + generated = lab / "generated/wlr" + generated.mkdir() + for name in ("config.h", "version.h"): + shutil.copyfile(Path("/usr/include/wlroots-0.20/wlr") / name, generated / name) + packages = ["scenefx-0.5", "wlroots-0.20", "wayland-server", "egl", "glesv2", "pixman-1", "libdrm"] + flags = shlex.split(capture(["/usr/bin/pkg-config", "--cflags", *packages]).decode()) + libs = shlex.split(capture(["/usr/bin/pkg-config", "--libs", *packages]).decode()) + if libs.index("-lscenefx-0.5") >= libs.index("-lwlroots-0.20"): + raise ValueError("ordre de liaison SceneFX puis wlroots requis") + base = ["/usr/bin/cc", "-std=c17", "-Wall", "-Wextra", "-Werror", + "-DWLR_USE_UNSTABLE", "-I" + str(lab / "generated"), + "-I" + str(lab / "sources/scenefx/include"), + "-I" + str(lab / "sources/wlroots/include"), *flags] + source = str(lab / "fixture/probe.c") + commands = [base + ["-fsyntax-only", source], + base + ["-M", "-MF", str(lab / "evidence/compiled.d"), source], + base + [source, "-Wl,-z,now", "-Wl,-z,relro", "-o", str(lab / "bin/probe"), *libs]] + for index, argv in enumerate(commands): + output = capture(argv) + durable(lab / "evidence" / f"compile-{index}.log", output) + deptext = (lab / "evidence/compiled.d").read_text().replace("\\\n", " ") + dependencies = [Path(name) for name in shlex.split(deptext.split(":", 1)[1])] + compared = [] + for dependency in dependencies: + for project, installed in (("scenefx", "/usr/include/scenefx-0.5"), + ("wlroots", "/usr/include/wlroots-0.20")): + root = lab / "sources" / project / "include" + if dependency.is_relative_to(root): + counterpart = Path(installed) / dependency.relative_to(root) + if counterpart.is_file(): + if dependency.read_bytes() != counterpart.read_bytes(): + raise ValueError(f"header installé différent du pin : {counterpart}") + compared.append(str(counterpart)) + vendor_json = Path(ENV["__EGL_VENDOR_LIBRARY_FILENAMES"]) + vendor_library = resolve_library(json.loads(vendor_json.read_text())["ICD"]["library_path"]) + # Mesa uses dlopen as well as DT_NEEDED. Seal explicit software DRI, + # GBM plugin and gallium provider, then their entire DT_NEEDED closure. + seeds = [lab / "bin/probe", Path("/usr/bin/bwrap"), Path("/usr/bin/prlimit"), + Path("/usr/bin/python3"), Path("/usr/bin/env"), Path("/usr/lib/ld-linux-x86-64.so.2"), + vendor_library, Path("/usr/lib/dri/swrast_dri.so")] + seeds.extend(Path("/usr/lib").glob("libgallium-*.so")) + seeds.append(Path("/usr/lib/gbm/dri_gbm.so")) + closure = elf_closure(seeds) + # Symbols must be definitions in the expected ELF, not merely declarations. + symbols = {} + expected = { + "/usr/lib/libscenefx-0.5.so": ["wlr_egl_create_with_drm_fd", "wlr_egl_destroy", + "wlr_egl_get_display", "fx_renderer_create_egl"], + "/usr/lib/libwlroots-0.20.so": ["wlr_renderer_get_render_formats"], + } + for filename, names in expected.items(): + table = capture(["/usr/bin/readelf", "--dyn-syms", "-W", filename]).decode() + for name in names: + if not any(line.split()[-1:] == [name] and " UND " not in line for line in table.splitlines()): + raise ValueError(f"symbole défini absent : {name}") + symbols[filename] = names + files = set(dependencies + [Path(name) for name in closure] + [vendor_json]) + files.update(Path(name) for name in compared) + files.update(path for root in ("fixture", "sources", "generated") + for path in (lab / root).rglob("*") if path.is_file()) + files.update(Path("/usr/bin") / name for name in ("cc", "pkg-config", "readelf", "git")) + files.update(Path("/usr/lib/pkgconfig") / (name + ".pc") for name in packages) + identities = {str(path): {"sha256": digest(path), "resolved": str(path.resolve())} + for path in sorted(files)} + manifest = { + "schema": 1, "lab": str(lab), "runtime": "NOT_RUN", "budget": 1, + "argv": command(lab), "environment": ENV, "supervisor_environment": HOST_ENV, + "pins": provenance, "files": identities, "elf_closure": closure, + "symbols": symbols, "compile_commands": commands, + "compared_installed_headers": compared, + "provenance_limit": "Installed ELF bytes are pinned, not reproduced from source. " + "Header equality and exported symbols do not prove binary source identity.", + "dlopen_limit": "Explicit Mesa EGL, swrast, GBM and gallium providers plus DT_NEEDED " + "are sealed; conditional runtime dlopen discovery remains NOT_RUN.", + } + durable(lab / "manifest.json", json.dumps(manifest, indent=2, sort_keys=True).encode() + b"\n") + seal = digest(lab / "manifest.json") + print(json.dumps({"runtime": "NOT_RUN", "manifest_sha256": seal, "plan_command": + ["/usr/bin/env", "-i", "PATH=/usr/bin", "LC_ALL=C", + "/usr/bin/python3", "-I", str(lab / "fixture/run.py"), "--lab", str(lab), + "--manifest-sha256", seal]}, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/tooling/probes/step20c-egl-fx/probe.c b/tooling/probes/step20c-egl-fx/probe.c new file mode 100644 index 0000000..9700849 --- /dev/null +++ b/tooling/probes/step20c-egl-fx/probe.c @@ -0,0 +1,253 @@ +#define _POSIX_C_SOURCE 200809L +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +/* CONTRACT: existing pinned internal declarations, never private layouts. */ +#include "render/egl.h" +#include "render/fx_renderer/fx_renderer.h" +#include "render/wlr_renderer.h" + +extern char **environ; +static const char *const environment[] = { + "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", +}; + +static bool guard_environment(void) { + size_t count = 0; + for (char **entry = environ; *entry != NULL; entry++) { + bool found = false; + for (size_t i = 0; i < sizeof(environment) / sizeof(environment[0]); i++) { + found |= strcmp(*entry, environment[i]) == 0; + } + if (!found) { + return false; + } + count++; + } + if (count != sizeof(environment) / sizeof(environment[0])) { + return false; + } + /* Duplicates cannot replace a required variable. */ + for (size_t i = 0; i < sizeof(environment) / sizeof(environment[0]); i++) { + bool found = false; + for (char **entry = environ; *entry != NULL; entry++) { + found |= strcmp(*entry, environment[i]) == 0; + } + if (!found) { + return false; + } + } + return true; +} + +static bool bounded(const char *value) { + return value != NULL && strnlen(value, 16385) <= 16384; +} + +static bool extension(const char *list, const char *name) { + size_t length = strlen(name); + for (const char *at = list; (at = strstr(at, name)) != NULL; at += length) { + if ((at == list || at[-1] == ' ') && + (at[length] == '\0' || at[length] == ' ')) { + return true; + } + } + return false; +} + +static void json_string(const char *value) { + putchar('"'); + for (const unsigned char *at = (const unsigned char *)value; *at; at++) { + if (*at == '"' || *at == '\\') { + printf("\\%c", *at); + } else if (*at < 32 || *at >= 127) { + printf("\\u%04x", *at); + } else { + putchar(*at); + } + } + putchar('"'); +} + +static bool formats_valid(const struct wlr_drm_format_set *set) { + /* NULL means this buffer capability is unsupported; sets remain borrowed. */ + if (set == NULL) { + return true; + } + if (set->len > 256 || (set->len && set->formats == NULL)) { + return false; + } + for (size_t i = 0; i < set->len; i++) { + const struct wlr_drm_format *format = &set->formats[i]; + if (format->len > 64 || (format->len && format->modifiers == NULL)) { + return false; + } + } + return true; +} + +static void formats_print(const char *name, const struct wlr_drm_format_set *set) { + printf("{\"stage\":\"formats\",\"usage\":"); + json_string(name); + printf(",\"supported\":%s,\"formats\":[", set ? "true" : "false"); + for (size_t i = 0; set && i < set->len; i++) { + const struct wlr_drm_format *format = &set->formats[i]; + printf("%s{\"fourcc\":%u,\"modifiers\":[", i ? "," : "", format->format); + for (size_t j = 0; j < format->len; j++) { + /* Strings preserve all 64 bits in JSON consumers. */ + printf("%s\"%016llx\"", j ? "," : "", + (unsigned long long)format->modifiers[j]); + } + printf("]}"); + } + puts("]}"); +} + +int main(int argc, char **argv) { + (void)argv; + int status = 20; /* 0: measurement complete; 20: refused/incomplete. */ + const char *reason = "environnement_non_conforme"; + struct wl_event_loop *loop = NULL; + struct wlr_backend *backend = NULL; + struct wlr_egl *egl = NULL; + struct wlr_renderer *renderer = NULL; + if (argc != 1 || !guard_environment()) { + goto cleanup; + } + reason = "creation_event_loop"; + if ((loop = wl_event_loop_create()) == NULL) { + goto cleanup; + } + reason = "creation_backend"; + if ((backend = wlr_headless_backend_create(loop)) == NULL) { + goto cleanup; + } + reason = "creation_egl_logiciel"; + if ((egl = wlr_egl_create_with_drm_fd(-1)) == NULL) { + goto cleanup; + } + EGLDisplay display = wlr_egl_get_display(egl); + const char *client = eglQueryString(EGL_NO_DISPLAY, EGL_EXTENSIONS); + const char *vendor = eglQueryString(display, EGL_VENDOR); + const char *version = eglQueryString(display, EGL_VERSION); + const char *extensions = eglQueryString(display, EGL_EXTENSIONS); + reason = "identite_egl_absente_ou_trop_longue"; + if (display == EGL_NO_DISPLAY || !bounded(client) || !bounded(vendor) || + !bounded(version) || !bounded(extensions) || !*vendor || !*version) { + goto cleanup; + } + reason = "device_query_absent"; + if (!extension(client, "EGL_EXT_device_query") && + !extension(client, "EGL_EXT_device_base")) { + goto cleanup; + } + PFNEGLQUERYDISPLAYATTRIBEXTPROC query_display = + (PFNEGLQUERYDISPLAYATTRIBEXTPROC)eglGetProcAddress("eglQueryDisplayAttribEXT"); + PFNEGLQUERYDEVICESTRINGEXTPROC query_device = + (PFNEGLQUERYDEVICESTRINGEXTPROC)eglGetProcAddress("eglQueryDeviceStringEXT"); + EGLAttrib device_attribute = 0; + reason = "device_selectionne_non_identifiable"; + if (!query_display || !query_device || + !query_display(display, EGL_DEVICE_EXT, &device_attribute) || + device_attribute == 0) { + goto cleanup; + } + /* WHY: inspect the display's device, never an unrelated enumerated device. */ + const char *device_extensions = query_device((EGLDeviceEXT)device_attribute, + EGL_EXTENSIONS); + if (!bounded(device_extensions)) { + goto cleanup; + } + bool software = extension(device_extensions, "EGL_MESA_device_software"); + bool drm = extension(device_extensions, "EGL_EXT_device_drm") || + extension(device_extensions, "EGL_EXT_device_drm_render_node"); + printf("{\"stage\":\"egl_selected\",\"vendor\":"); json_string(vendor); + printf(",\"version\":"); json_string(version); + printf(",\"extensions\":"); json_string(extensions); + printf(",\"client_extensions\":"); json_string(client); + printf(",\"device_extensions\":"); json_string(device_extensions); + printf(",\"selected_device_software\":%s,\"selected_device_drm\":%s}\n", + software ? "true" : "false", drm ? "true" : "false"); + /* Evidence survives a constructor failure or a supervisor timeout. */ + if (fflush(stdout) != 0) { + reason = "sortie_egl"; + goto cleanup; + } + reason = "device_non_logiciel_ou_drm"; + if (!software || drm) { + goto cleanup; + } + reason = "creation_fx"; + if ((renderer = fx_renderer_create_egl(egl)) == NULL) { + goto cleanup; + } + /* INVARIANT: success transfers ownership; destroy renderer OR EGL. */ + egl = NULL; + int backend_fd = wlr_backend_get_drm_fd(backend); + int renderer_fd = wlr_renderer_get_drm_fd(renderer); + reason = "fd_drm_inattendu"; + if (backend_fd != -1 || renderer_fd != -1) { + goto cleanup; + } + const struct wlr_drm_format_set *sets[] = { + wlr_renderer_get_render_formats(renderer), + wlr_renderer_get_texture_formats(renderer, WLR_BUFFER_CAP_DMABUF), + wlr_renderer_get_texture_formats(renderer, WLR_BUFFER_CAP_DATA_PTR), + wlr_renderer_get_texture_formats(renderer, WLR_BUFFER_CAP_SHM), + }; + const char *names[] = {"render_target", "texture_dmabuf", "texture_data_ptr", "texture_shm"}; + reason = "formats_hors_bornes"; + size_t modifiers = 0; + for (size_t i = 0; i < 4; i++) { + if (!formats_valid(sets[i])) { + goto cleanup; + } + for (size_t j = 0; sets[i] && j < sets[i]->len; j++) { + modifiers += sets[i]->formats[j].len; + } + } + if (modifiers > 4096) { + goto cleanup; + } + for (size_t i = 0; i < 4; i++) { + formats_print(names[i], sets[i]); + } + bool blocked = renderer->render_buffer_caps == WLR_BUFFER_CAP_DMABUF; + printf("{\"stage\":\"measurement\",\"backend_drm_fd\":%d," + "\"renderer_drm_fd\":%d,\"backend_buffer_caps\":%u," + "\"renderer_render_buffer_caps\":%u,\"target_allocation\":\"NOT_ATTEMPTED\"," + "\"allocator_eligibility\":\"%s\"}\n", backend_fd, renderer_fd, + backend->buffer_caps, renderer->render_buffer_caps, + blocked ? "PINNED_DMABUF_ROUTE_BLOCKED_WITHOUT_DRM_OR_UDMABUF" : "UNDETERMINED"); + status = 0; + reason = "mesure_terminee_allocation_non_tentee"; +cleanup: + if (renderer != NULL) { + wlr_renderer_destroy(renderer); + } else if (egl != NULL) { + wlr_egl_destroy(egl); + } + if (backend != NULL) { + wlr_backend_destroy(backend); + } + if (loop != NULL) { + wl_event_loop_destroy(loop); + } + printf("{\"stage\":\"completion\",\"status\":%d,\"reason\":", status); + json_string(reason); + puts("}"); + return fflush(stdout) == 0 && !ferror(stdout) ? status : 20; +} diff --git a/tooling/probes/step20c-egl-fx/run.py b/tooling/probes/step20c-egl-fx/run.py new file mode 100644 index 0000000..1808ffa --- /dev/null +++ b/tooling/probes/step20c-egl-fx/run.py @@ -0,0 +1,243 @@ +#!/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()) diff --git a/tooling/probes/step20c-egl-fx/test_probe.py b/tooling/probes/step20c-egl-fx/test_probe.py new file mode 100644 index 0000000..29e8cc0 --- /dev/null +++ b/tooling/probes/step20c-egl-fx/test_probe.py @@ -0,0 +1,225 @@ +"""Tests offline uniquement ; aucun bwrap, aucun helper EGL/FX exécuté.""" +import contextlib +import io +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +import run + + +class ProtocolTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.lab = Path(self.temporary.name) + self.lab.chmod(0o700) + (self.lab / "bin").mkdir() + (self.lab / "fixture").mkdir() + (self.lab / "bin/probe").write_bytes(b"INERT-NOT-ELF") + (self.lab / "fixture/run.py").write_text("# inert\n") + self.files = [self.lab / "bin/probe", self.lab / "fixture/run.py", + Path("/usr/bin/bwrap"), Path("/usr/bin/prlimit")] + self.manifest = { + "schema": 1, "lab": str(self.lab), "argv": run.command(self.lab), + "environment": run.ENV, "supervisor_environment": run.HOST_ENV, + "files": {str(path): {"sha256": run.digest(path), "resolved": str(path.resolve())} + for path in self.files}, + } + + def seal(self): + (self.lab / "manifest.json").write_text(json.dumps(self.manifest)) + return run.digest(self.lab / "manifest.json") + + def verify(self, seal=None): + if seal is None: + seal = self.seal() + with patch.object(run, "__file__", str(self.lab / "fixture/run.py")), \ + patch.dict(os.environ, run.HOST_ENV, clear=True): + return run.verify(self.lab, seal) + + def test_valid_identity(self): + self.assertEqual(self.verify()["schema"], 1) + + def test_manifest_drift(self): + seal = self.seal() + (self.lab / "manifest.json").write_text("{}") + with self.assertRaises(run.Refusal): + self.verify(seal) + + def test_binary_drift(self): + seal = self.seal() + (self.lab / "bin/probe").write_bytes(b"CHANGED") + with self.assertRaises(run.Refusal): + self.verify(seal) + + def test_script_identity_drift(self): + seal = self.seal() + (self.lab / "fixture/run.py").write_text("# changed\n") + with self.assertRaises(run.Refusal): + self.verify(seal) + + def test_forbidden_mount_and_devices(self): + for device in ("/dev/dri", "/dev/input", "/dev/udmabuf", "/etc", "/sys", "/run/user"): + with self.subTest(device=device): + self.manifest["argv"] = run.command(self.lab) + ["--ro-bind", device, device] + with self.assertRaises(run.Refusal): + self.verify() + + def test_environment_pwd_and_extra(self): + for altered in ({**run.ENV, "PWD": "/"}, {**run.ENV, "HOME": "/home/host"}): + self.manifest["environment"] = altered + with self.assertRaises(run.Refusal): + self.verify() + + def test_supervisor_inherited_environment(self): + seal = self.seal() + with patch.dict(os.environ, {**run.HOST_ENV, "LD_PRELOAD": "bad"}, clear=True): + with self.assertRaises(run.Refusal): + run.verify(self.lab, seal) + + def test_wrong_launcher_identity(self): + seal = self.seal() + with patch.dict(os.environ, run.HOST_ENV, clear=True): + with self.assertRaises(run.Refusal): + run.verify(self.lab, seal) + + def test_private_mode(self): + self.lab.chmod(0o755) + with self.assertRaises(run.Refusal): + self.verify() + + def test_budget_exclusive_no_retry(self): + run.durable(self.lab / "ATTEMPT", b"one", exclusive=True) + with self.assertRaises(FileExistsError): + run.durable(self.lab / "ATTEMPT", b"two", exclusive=True) + self.assertEqual((self.lab / "ATTEMPT").read_bytes(), b"one") + + def test_default_plan_has_no_write_or_spawn(self): + before = sorted(self.lab.rglob("*")) + with patch.object(sys, "argv", ["run.py", "--lab", str(self.lab), "--manifest-sha256", "x"]), \ + patch.object(run, "verify", return_value=self.manifest), \ + patch.object(run, "durable", side_effect=AssertionError("write")), \ + patch.object(subprocess, "Popen", side_effect=AssertionError("native boundary")), \ + contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(run.main(), 0) + self.assertEqual(sorted(self.lab.rglob("*")), before) + + def test_invalid_identity_stops_before_native_boundary(self): + with patch.object(sys, "argv", ["run.py", "--lab", str(self.lab), "--manifest-sha256", "bad", + "--approve-native-probe"]), \ + patch.object(run, "supervise", side_effect=AssertionError("native boundary")), \ + contextlib.redirect_stderr(io.StringIO()): + self.assertEqual(run.main(), 20) + self.assertFalse((self.lab / "ATTEMPT").exists()) + + def test_consumed_budget_refuses_passive_plan(self): + run.durable(self.lab / "ATTEMPT", b"one", exclusive=True) + with patch.object(sys, "argv", ["run.py", "--lab", str(self.lab), "--manifest-sha256", "x"]), \ + patch.object(run, "verify", return_value=self.manifest), \ + contextlib.redirect_stderr(io.StringIO()): + self.assertEqual(run.main(), 20) + + def test_spawn_failure_consumes_budget(self): + arguments = ["run.py", "--lab", str(self.lab), "--manifest-sha256", "test", "--approve-native-probe"] + with patch.object(sys, "argv", arguments), \ + patch.object(run, "verify", return_value=self.manifest), \ + patch.object(run, "supervise", side_effect=OSError("mock spawn failure")) as native, \ + contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): + self.assertEqual(run.main(), 20) + self.assertEqual(run.main(), 20) + self.assertEqual(native.call_count, 1) + self.assertEqual(json.loads((self.lab / "result.json").read_text())["cause"], "spawn_failure") + + +class DrainTests(unittest.TestCase): + def fake_python(self, script, **kwargs): + return run.supervise([sys.executable, "-I", "-c", script], **kwargs) + + def test_exit_and_channels(self): + result, channels = self.fake_python("import os; os.write(1,b'out'); os.write(2,b'err')") + self.assertEqual(result["returncode"], 0) + self.assertTrue(result["collected"]) + self.assertEqual(channels, [b"out", b"err"]) + + def test_nonzero_exit(self): + result, _ = self.fake_python("raise SystemExit(7)") + self.assertEqual(result["returncode"], 7) + self.assertEqual(result["cause"], "exit") + + def test_exact_output_bound(self): + result, channels = self.fake_python(f"import os; os.write(1,b'x'*{run.LIMIT}); os.write(2,b'y'*{run.LIMIT})") + self.assertEqual(result["cause"], "exit") + self.assertEqual(list(map(len, channels)), [run.LIMIT, run.LIMIT]) + + def test_output_overflow(self): + result, channels = self.fake_python(f"import os; os.write(1,b'x'*{run.LIMIT + 1})") + self.assertEqual(result["cause"], "output_overflow") + self.assertEqual(len(channels[0]), run.LIMIT) + self.assertTrue(result["collected"]) + + def test_timeout_collection(self): + result, _ = self.fake_python("import time; time.sleep(30)", payload_seconds=0.1, total_seconds=0.8) + self.assertEqual(result["cause"], "timeout") + self.assertTrue(result["collected"]) + self.assertLess(result["elapsed"], 1.0) + + def test_closed_pipes_preserve_cleanup_reserve(self): + result, _ = self.fake_python("import os,time; os.close(1); os.close(2); time.sleep(30)", + payload_seconds=0.1, total_seconds=0.8) + self.assertEqual(result["cause"], "timeout") + self.assertTrue(result["collected"]) + self.assertLess(result["elapsed"], 0.6) + + def test_read_failure_collects_owned_child(self): + real_popen = subprocess.Popen + children = [] + def remember(*args, **kwargs): + child = real_popen(*args, **kwargs) + children.append(child) + return child + real_read = os.read + def fail_drain(fd, size): + if size == 65536: + raise OSError("mock read failure") + return real_read(fd, size) + with patch.object(run.subprocess, "Popen", side_effect=remember), \ + patch.object(run.os, "read", side_effect=fail_drain): + result, _ = self.fake_python("import os,time; os.write(1,b'x'); time.sleep(30)") + self.assertEqual(result["cause"], "supervisor_failure") + self.assertIsNotNone(children[0].returncode) + + def test_collection_failure_explicit(self): + real_wait = subprocess.Popen.wait + def delayed_wait(child, *args, **kwargs): + real_wait(child, timeout=1) + raise subprocess.TimeoutExpired("mock collection", 0) + with patch.object(subprocess.Popen, "wait", delayed_wait): + result, _ = self.fake_python("pass", payload_seconds=0.05, total_seconds=0.2) + self.assertEqual(result["cause"], "cleanup_unproven") + self.assertFalse(result["collected"]) + + +class SourceTests(unittest.TestCase): + def test_device_guard_precedes_fx_and_ownership(self): + source = Path(__file__).with_name("probe.c").read_text() + self.assertLess(source.index("query_display(display, EGL_DEVICE_EXT"), + source.index("renderer = fx_renderer_create_egl(egl)")) + self.assertLess(source.index("if (!software || drm)"), source.index("renderer = fx_renderer_create_egl(egl)")) + self.assertIn("} else if (egl != NULL)", source) + self.assertIn("egl = NULL;", source) + for forbidden in ("eglQueryDevicesEXT(", "wlr_fx_renderer_get_egl(", "wl_display_create(", + "wlr_backend_start(", "wlr_headless_add_output(", "wlr_allocator_autocreate(", + "wlr_buffer_create(", "glDraw", "->texture_caps", "->egl", "->display"): + self.assertNotIn(forbidden, source) + self.assertIn('"PWD=/tmp"', source) + self.assertIn("modifiers > 4096", source) + + +if __name__ == "__main__": + unittest.main() diff --git a/tooling/probes/step20c-egl-fx/test_probe_mocks.c b/tooling/probes/step20c-egl-fx/test_probe_mocks.c new file mode 100644 index 0000000..a2b3555 --- /dev/null +++ b/tooling/probes/step20c-egl-fx/test_probe_mocks.c @@ -0,0 +1,158 @@ +/* Offline harness: every native EGL/Wayland/wlroots entrypoint is replaced. + * This executable is NOT the real probe and links no EGL/FX/wlroots library. */ +#define main probe_main +#define environ mock_environ +#define wl_event_loop_create mock_loop_create +#define wl_event_loop_destroy mock_loop_destroy +#define wlr_headless_backend_create mock_backend_create +#define wlr_backend_destroy mock_backend_destroy +#define wlr_backend_get_drm_fd mock_backend_fd +#define wlr_egl_create_with_drm_fd mock_egl_create +#define wlr_egl_destroy mock_egl_destroy +#define wlr_egl_get_display mock_egl_display +#define eglQueryString mock_query_string +#define eglGetProcAddress mock_get_proc +#define fx_renderer_create_egl mock_fx_create +#define wlr_renderer_destroy mock_renderer_destroy +#define wlr_renderer_get_drm_fd mock_renderer_fd +#define wlr_renderer_get_render_formats mock_render_formats +#define wlr_renderer_get_texture_formats mock_texture_formats +#include "probe.c" +#undef main +#include + +static int mode, fx_calls, egl_destroyed, renderer_destroyed, order; +static unsigned texture_queries; +static struct wlr_backend backend_object; +static struct wlr_renderer renderer_object; +static struct wlr_drm_format format_object = {.format = 875713112}; +static struct wlr_drm_format_set format_set = {.len = 1, .formats = &format_object}; +static struct wlr_drm_format_set excessive_set = {.len = 257}; +static const char *device_extensions; +static char *mock_environment[] = { + "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", NULL, +}; + +char **mock_environ = mock_environment; + +struct wl_event_loop *mock_loop_create(void) { + return (struct wl_event_loop *)(uintptr_t)1; +} +void mock_loop_destroy(struct wl_event_loop *loop) { + assert(loop == (struct wl_event_loop *)(uintptr_t)1 && order == 2); + order = 3; +} +struct wlr_backend *mock_backend_create(struct wl_event_loop *loop) { + assert(loop == (struct wl_event_loop *)(uintptr_t)1); + return &backend_object; +} +void mock_backend_destroy(struct wlr_backend *backend) { + assert(backend == &backend_object && order == 1); + order = 2; +} +int mock_backend_fd(struct wlr_backend *backend) { + assert(backend == &backend_object); + return -1; +} +struct wlr_egl *mock_egl_create(int fd) { + assert(fd == -1); + return (struct wlr_egl *)(uintptr_t)2; +} +void mock_egl_destroy(struct wlr_egl *egl) { + assert(egl == (struct wlr_egl *)(uintptr_t)2 && order == 0); + assert(renderer_destroyed == 0); + egl_destroyed++; + order = 1; +} +EGLDisplay mock_egl_display(struct wlr_egl *egl) { + assert(egl == (struct wlr_egl *)(uintptr_t)2); + return (EGLDisplay)(uintptr_t)3; +} +const char *mock_query_string(EGLDisplay display, EGLint name) { + if (display == EGL_NO_DISPLAY) { + return "EGL_EXT_device_query"; + } + assert(display == (EGLDisplay)(uintptr_t)3); + return name == EGL_EXTENSIONS ? "EGL_EXT_image_dma_buf_import" : "mock"; +} +static EGLBoolean mock_query_display(EGLDisplay display, EGLint attribute, EGLAttrib *value) { + assert(display == (EGLDisplay)(uintptr_t)3 && attribute == EGL_DEVICE_EXT); + *value = 4; + return mode != 5; +} +static const char *mock_query_device(EGLDeviceEXT device, EGLint name) { + /* An arbitrary enumerated device would violate this assertion. */ + assert(device == (EGLDeviceEXT)(uintptr_t)4 && name == EGL_EXTENSIONS); + return device_extensions; +} +__eglMustCastToProperFunctionPointerType mock_get_proc(const char *name) { + if (strcmp(name, "eglQueryDisplayAttribEXT") == 0) { + return (__eglMustCastToProperFunctionPointerType)mock_query_display; + } + assert(strcmp(name, "eglQueryDeviceStringEXT") == 0); + return (__eglMustCastToProperFunctionPointerType)mock_query_device; +} +struct wlr_renderer *mock_fx_create(struct wlr_egl *egl) { + assert(egl == (struct wlr_egl *)(uintptr_t)2); + fx_calls++; + return mode == 1 ? NULL : &renderer_object; +} +void mock_renderer_destroy(struct wlr_renderer *renderer) { + assert(renderer == &renderer_object && order == 0 && egl_destroyed == 0); + renderer_destroyed++; + /* Mock renderer consumes the transferred EGL, without a second helper free. */ + order = 1; +} +int mock_renderer_fd(struct wlr_renderer *renderer) { + assert(renderer == &renderer_object); + return mode == 7 ? 42 : -1; +} +const struct wlr_drm_format_set *mock_render_formats(struct wlr_renderer *renderer) { + assert(renderer == &renderer_object); + return mode == 6 ? &excessive_set : &format_set; +} +const struct wlr_drm_format_set *mock_texture_formats(struct wlr_renderer *renderer, + uint32_t caps) { + assert(renderer == &renderer_object); + assert(caps == WLR_BUFFER_CAP_DMABUF || caps == WLR_BUFFER_CAP_DATA_PTR || + caps == WLR_BUFFER_CAP_SHM); + assert((texture_queries & caps) == 0); + texture_queries |= caps; + return caps == WLR_BUFFER_CAP_DMABUF ? &format_set : NULL; +} + +int main(void) { + /* Keep bounded test output separate from simulated JSONL evidence. */ + assert(freopen("/dev/null", "w", stdout) != NULL); + for (mode = 0; mode < 8; mode++) { + fx_calls = egl_destroyed = renderer_destroyed = order = 0; + texture_queries = 0; + device_extensions = "EGL_MESA_device_software"; + if (mode == 2) { + device_extensions = "EGL_EXT_device_drm"; + } else if (mode == 3) { + device_extensions = "EGL_MESA_device_software EGL_EXT_device_drm_render_node"; + } else if (mode == 4) { + device_extensions = "EGL_MESA_device_software_suffix"; + } + renderer_object.render_buffer_caps = WLR_BUFFER_CAP_DMABUF; + char *argv[] = {"inert-mock", NULL}; + assert(probe_main(1, argv) == (mode == 0 ? 0 : 20)); + assert(order == 3); + if (mode >= 2 && mode <= 5) { + assert(fx_calls == 0 && egl_destroyed == 1 && renderer_destroyed == 0); + } else if (mode == 1) { + assert(fx_calls == 1 && egl_destroyed == 1 && renderer_destroyed == 0); + } else { + assert(fx_calls == 1 && egl_destroyed == 0 && renderer_destroyed == 1); + } + if (mode == 0 || mode == 6) { + assert(texture_queries == (WLR_BUFFER_CAP_DMABUF | WLR_BUFFER_CAP_DATA_PTR | + WLR_BUFFER_CAP_SHM)); + } + } + fputs("8 mock C scenarios passed; no EGL/FX library linked\n", stderr); + return 0; +}