test(mosaic): prepare isolated EGL FX probe without native execution
This commit is contained in:
parent
ccd534132d
commit
99f1fbeb40
8 changed files with 1331 additions and 0 deletions
|
|
@ -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)
|
||||
|
|
|
|||
140
docs/design/STEP20C_HEADLESS_EGL_FX_PROBE.md
Normal file
140
docs/design/STEP20C_HEADLESS_EGL_FX_PROBE.md
Normal file
|
|
@ -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.
|
||||
109
tooling/probes/step20c-egl-fx/README.md
Normal file
109
tooling/probes/step20c-egl-fx/README.md
Normal file
|
|
@ -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éé.
|
||||
198
tooling/probes/step20c-egl-fx/prepare.py
Normal file
198
tooling/probes/step20c-egl-fx/prepare.py
Normal file
|
|
@ -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()
|
||||
253
tooling/probes/step20c-egl-fx/probe.c
Normal file
253
tooling/probes/step20c-egl-fx/probe.c
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
#define _POSIX_C_SOURCE 200809L
|
||||
#include <EGL/egl.h>
|
||||
#include <EGL/eglext.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <wayland-server-core.h>
|
||||
#include <wlr/backend/headless.h>
|
||||
#include <wlr/render/drm_format_set.h>
|
||||
#include <wlr/render/egl.h>
|
||||
#include <wlr/render/wlr_renderer.h>
|
||||
#include <wlr/types/wlr_buffer.h>
|
||||
/* 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;
|
||||
}
|
||||
243
tooling/probes/step20c-egl-fx/run.py
Normal file
243
tooling/probes/step20c-egl-fx/run.py
Normal file
|
|
@ -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())
|
||||
225
tooling/probes/step20c-egl-fx/test_probe.py
Normal file
225
tooling/probes/step20c-egl-fx/test_probe.py
Normal file
|
|
@ -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()
|
||||
158
tooling/probes/step20c-egl-fx/test_probe_mocks.c
Normal file
158
tooling/probes/step20c-egl-fx/test_probe_mocks.c
Normal file
|
|
@ -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 <assert.h>
|
||||
|
||||
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;
|
||||
}
|
||||
Loading…
Reference in a new issue