From 5edd962e04f7da30d4a9da7606c9b3b03ede35bc Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Wed, 9 Sep 2026 21:08:19 -0500 Subject: [PATCH 01/27] docs(cua-driver): define native plugin rebuild profile contract --- .../packaging/release/profile-contract.md | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 libs/cua-driver/hyprland-plugin/packaging/release/profile-contract.md diff --git a/libs/cua-driver/hyprland-plugin/packaging/release/profile-contract.md b/libs/cua-driver/hyprland-plugin/packaging/release/profile-contract.md new file mode 100644 index 0000000000..8255f76bb0 --- /dev/null +++ b/libs/cua-driver/hyprland-plugin/packaging/release/profile-contract.md @@ -0,0 +1,71 @@ +# Profile-based native rebuilds + +This packaging follow-up to [RFC 3550](https://github.com/trycua/cua/issues/3550) +keeps plugin source, packaging tooling, and native qualification separate. +It prepares an optional package for one measured Omarchy x86_64 environment. +A profile or successful build is not native certification. + +## Immutable inputs + +Preserve the published Driver 0.24.0 plugin source archive and its checksums. +The archive's embedded verifier and manifest describe its historical release +environment. A rebuild kit supplies a separately identified verifier, reviewed +environment profile, recipe, and checksums. The new recipe must verify the +original archive and source inventory, use the kit verifier explicitly, and +not rewrite or invoke the embedded historical verifier. + +Record the source archive digest and revision, tooling revision, profile +digest, native build inputs, resulting module and package digests, and separate +qualification evidence. A profile is reviewed data, not an instruction to +accept whatever environment the builder discovers. Preserve exact compositor, +headers, compiler, shared-runtime, source-integrity, and production-build checks. + +Changing the native input implementation or Driver's application admission is +outside a packaging-only rebuild. Such changes need a reviewed source revision +and their affected native evidence. + +## Initial qualification + +Measure the intended channel's actual compositor, headers, compiler, shared +runtime, application packages, and installed Driver before committing a native +profile. Keep publication restricted to that one channel while qualifying it. +Do not downgrade the desktop or relax compatibility guards to fit old pins. + +Inkscape 1.4.4-6 is the first application candidate because it matches Driver +0.24.0's admission contract. Two-lane proof requires distinct native Wayland +clients and independent Driver processes; two windows alone are insufficient. +Current LibreOffice versions outside the existing admission contract remain +unsupported until separately qualified. Do not silently reduce the two-lane +scope or widen production admission to satisfy a test fixture. + +Use the complete native Linux runner selected in the +[test-harnesses guide](../../../../docs/test-harnesses-guide.md), plus bounded +application, two-lane overlap, third-owner refusal, primary-input isolation, +conflict, stale-target/geometry, cancellation, and cleanup evidence. Preserve +the canonical runner's assertions. Instrumented diagnostics and the shipped +trace-disabled package require distinct identities and fresh-session results. +Application output and independent primary-input observations remain required. + +## Distribution and maintenance + +Keep installation opt-in without configuration edits, autoload, install hooks, +or hot replacement. Exact ABI-relevant package dependencies and the documented +consumer compatibility check must cover the supported activation path without +requiring a compiler on the consumer machine. + +Before publication, verify real package installation, removal, reinstallation, +fresh-session activation, upgrade, and rollback. When a matching replacement is +unavailable, exit the graphical session, remove the optional plugin, update the +desktop, and verify a fresh session. Disabling input does not remove a package +dependency. Retain a matching rollback set. + +Cua owns source tooling and native input qualification. The distribution names +its package-maintenance and signing owner before rollout. Relevant dependency +changes trigger a candidate build and requalification, not an automatic +compatibility claim. Manual publication must use the certified package bytes; +unattended distribution requires an enforced artifact-to-evidence gate. + +The downstream implementation remains +[omacom/omarchy-pkgs#346](https://github.com/omacom/omarchy-pkgs/pull/346). +This document records the selected contract, not completed package or native +validation. From 9cc151ec0010b62fb83c0a5dd86c2db2154a2728 Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Wed, 9 Sep 2026 21:21:34 -0500 Subject: [PATCH 02/27] build(cua-driver): prepare immutable Hyprland profile kits Refs #3550. Preserve Driver 0.24.0 plugin source and separate tooling, environment, and native evidence identities. Add explicit Inkscape-only qualification harness profile without widening production admission. --- libs/cua-driver/docs/test-harnesses-guide.md | 8 + .../packaging/release/PROFILE-PKGBUILD.in | 69 +++ .../packaging/release/PROFILE-USAGE.md | 97 ++++ .../packaging/release/README.md | 54 +++ .../packaging/release/lifecycle.py | 98 +++- .../packaging/release/profile-contract.md | 2 +- .../packaging/release/profile_bundle.py | 88 ++++ .../packaging/release/profile_verify.py | 322 +++++++++++++ .../packaging/release/test_profile_release.py | 427 ++++++++++++++++++ .../tests/production-inkscape-profile.md | 100 ++++ .../tests/production_active_lock_proof.py | 3 +- .../tests/production_active_primary_proof.py | 3 +- .../tests/production_app_profile_test.py | 267 +++++++++++ .../tests/production_app_smoke.py | 119 ++++- .../tests/production_cancel_proof.py | 3 +- .../tests/production_desktop_fault_proof.py | 3 +- .../tests/production_geometry_fault_proof.py | 3 +- .../tests/production_idle_reconnect_proof.py | 3 +- .../tests/production_lock_refusal_proof.py | 3 +- .../tests/production_policy_proof.py | 6 +- .../production_primary_conflict_proof.py | 3 +- .../tests/production_realapp_proof.py | 106 ++++- .../tests/production_realapp_proof_test.py | 27 +- .../tests/production_session_fault_proof.py | 3 +- .../tests/production_target_lifetime_proof.py | 3 +- 25 files changed, 1758 insertions(+), 62 deletions(-) create mode 100644 libs/cua-driver/hyprland-plugin/packaging/release/PROFILE-PKGBUILD.in create mode 100644 libs/cua-driver/hyprland-plugin/packaging/release/PROFILE-USAGE.md create mode 100644 libs/cua-driver/hyprland-plugin/packaging/release/profile_bundle.py create mode 100644 libs/cua-driver/hyprland-plugin/packaging/release/profile_verify.py create mode 100644 libs/cua-driver/hyprland-plugin/packaging/release/test_profile_release.py create mode 100644 libs/cua-driver/hyprland-plugin/tests/production-inkscape-profile.md create mode 100644 libs/cua-driver/hyprland-plugin/tests/production_app_profile_test.py diff --git a/libs/cua-driver/docs/test-harnesses-guide.md b/libs/cua-driver/docs/test-harnesses-guide.md index 35cace94dd..97327c2a3d 100644 --- a/libs/cua-driver/docs/test-harnesses-guide.md +++ b/libs/cua-driver/docs/test-harnesses-guide.md @@ -124,6 +124,14 @@ semantic AT-SPI actions are separate. See [production proof preparation](../hyprland-plugin/tests/production-proof.md) for the bounded plans and their limits. +The explicit [Inkscape-only qualification profile](../hyprland-plugin/tests/production-inkscape-profile.md) +supports a bounded packaging candidate using exact Inkscape `1.4.4-6`, with +independent native clients, two app lanes, separate SVG oracles, and third-owner +capacity refusal. It preserves the default Calc/Inkscape profile and the native +all-suite gate. Product, harness, kit, and mapped module identities remain +separate; adding the profile records no new native passing result and does not +let diagnostic trace evidence certify trace-disabled package bytes. + Three complete repetitions of the long Python Calc/Inkscape plan, including the 34 policy cases across both apps, are no longer a merge requirement. Extended Python stress runs remain diagnostics for specific unresolved diff --git a/libs/cua-driver/hyprland-plugin/packaging/release/PROFILE-PKGBUILD.in b/libs/cua-driver/hyprland-plugin/packaging/release/PROFILE-PKGBUILD.in new file mode 100644 index 0000000000..fd095c1262 --- /dev/null +++ b/libs/cua-driver/hyprland-plugin/packaging/release/PROFILE-PKGBUILD.in @@ -0,0 +1,69 @@ +# Profile kit: original source bytes and separately committed packaging tooling. +# shellcheck shell=bash disable=SC2034,SC2154 +pkgname=cua-hyprland-plugin +pkgver=@DRIVER_VERSION@ +pkgrel=@PKGREL@ +pkgdesc='Cua input candidate for reviewed profile @PROFILE_ID@' +arch=('x86_64') +url='https://github.com/trycua/cua' +license=('MIT') +depends=('hyprland=@HYPRLAND_PACKAGE@' @RUNTIME_DEPENDS@ 'python>=3.11' 'binutils') +makedepends=('cmake>=3.30' 'ninja' 'pkgconf' 'gcc') +options=('!strip' '!debug' '!lto') +_stem='@STEM@' +_archive_sha256='@ARCHIVE_SHA256@' +_kit_sha256='@KIT_SHA256@' +_profile_sha256='@PROFILE_SHA256@' +_verifier_sha256='@VERIFIER_SHA256@' +_cxx="${CUA_RELEASE_CXX:-/usr/bin/g++}" +source=("${_stem}.tar.gz" 'KIT-PROVENANCE.json' 'PROFILE.json' 'profile_verify.py') +sha256sums=('@ARCHIVE_SHA256@' '@KIT_SHA256@' '@PROFILE_SHA256@' '@VERIFIER_SHA256@') + +_verify() { + # Explicit checks still apply to --skipinteg, --noextract and --repackage. + printf '%s %s\n' "$_archive_sha256" "$startdir/${_stem}.tar.gz" | sha256sum -c - || return 1 + printf '%s %s\n' "$_kit_sha256" "$startdir/KIT-PROVENANCE.json" | sha256sum -c - || return 1 + printf '%s %s\n' "$_profile_sha256" "$startdir/PROFILE.json" | sha256sum -c - || return 1 + printf '%s %s\n' "$_verifier_sha256" "$startdir/profile_verify.py" | sha256sum -c - || return 1 + python3 "$startdir/profile_verify.py" --kit "$startdir" --kit-sha256 "$_kit_sha256" \ + --archive "$startdir/${_stem}.tar.gz" --source "$srcdir/$_stem" --cxx "$_cxx" "$@" +} + +prepare() { + _verify +} + +build() { + _verify || return 1 + cmake -S "$srcdir/$_stem" -B "$srcdir/build" -G Ninja \ + -DCMAKE_BUILD_TYPE=Release -DCMAKE_CXX_COMPILER="$_cxx" \ + -DBUILD_TESTING=ON -DCUA_HYPRLAND_BUILD_PLUGIN=ON \ + -DCUA_HYPRLAND_EXPECTED_VERSION=0.56.2 \ + -DCUA_HYPRLAND_INPUT=ON -DCUA_HYPRLAND_TEST_INPUT=OFF \ + -DCUA_HYPRLAND_INPUT_TRACE=OFF -DCUA_HYPRLAND_TEST_OPERATOR_KEY= || return 1 + cmake --build "$srcdir/build" +} + +check() { + _verify || return 1 + ( + unset LD_PRELOAD FAKEROOTKEY FAKED_MODE + ctest --test-dir "$srcdir/build" --output-on-failure --no-tests=error + ) +} + +package() { + check || return 1 + _verify --build "$srcdir/build" --output "$srcdir/BUILD-PROVENANCE.json" || return 1 + install -Dm755 "$srcdir/build/cua-hyprland-plugin.so" \ + "$pkgdir/usr/lib/cua/hyprland/cua-hyprland-plugin.so" || return 1 + install -Dm644 "$srcdir/$_stem/LICENSE.md" \ + "$pkgdir/usr/share/licenses/$pkgname/LICENSE" || return 1 + install -Dm644 "$srcdir/$_stem/SOURCE-PROVENANCE.json" \ + "$pkgdir/usr/share/$pkgname/SOURCE-PROVENANCE.json" || return 1 + local name + install -Dm644 "$srcdir/BUILD-PROVENANCE.json" "$pkgdir/usr/share/$pkgname/BUILD-PROVENANCE.json" || return 1 + for name in KIT-PROVENANCE.json PROFILE.json profile_verify.py; do + install -Dm644 "$startdir/$name" "$pkgdir/usr/share/$pkgname/$name" || return 1 + done +} diff --git a/libs/cua-driver/hyprland-plugin/packaging/release/PROFILE-USAGE.md b/libs/cua-driver/hyprland-plugin/packaging/release/PROFILE-USAGE.md new file mode 100644 index 0000000000..c5208dc781 --- /dev/null +++ b/libs/cua-driver/hyprland-plugin/packaging/release/PROFILE-USAGE.md @@ -0,0 +1,97 @@ +# Build a reviewed native-profile package + +This kit rebuilds the unchanged Driver 0.24.0 plugin source for one explicitly +reviewed native profile. The historical archive and its embedded manifest and +verifier retain their original bytes. The recipe uses `profile_verify.py` from +this kit. It does not invoke the historical verifier or rewrite source files. + +The kit, source, and native profile have separate identities. `PROFILE.json` +contains the profile ID, kit version, package release, source checksums, and +measured compiler, compositor, header-tree and shared-runtime identities. +`KIT-PROVENANCE.json` binds that profile's exact bytes to the committed tooling +and fixed production build options. `SHA256SUMS` includes the recipe, tooling, +profile, manifests and original source archive. None of these files asserts +native certification; use separately reviewed evidence for the exact package +bytes and environment before rollout. + +## Prepare and inspect the kit + +Obtain the kit and its outer checksum from the reviewed distribution channel. +Do not accept a newly downloaded profile because its values match your machine. +Verify the outer archive checksum against the independently reviewed value, +then extract into a dedicated empty directory and run: + +```sh +sha256sum -c SHA256SUMS +``` + +Review `PKGBUILD`, `PROFILE.json`, and `KIT-PROVENANCE.json`. Retain the reviewed +SHA-256 of `KIT-PROVENANCE.json` separately for lifecycle and consumer checks. +Checksums establish agreement with reviewed files, not publisher authenticity. + +Run `makepkg` as an ordinary user. The original source tarball is already in the +kit; no Git checkout or network source resolution is needed. The matching native +compiler, headers, runtime, CMake, Ninja, Python, pkg-config and binutils must +already be installed. To select a compiler outside `/usr/bin/g++`, set +`CUA_RELEASE_CXX` to its absolute path. The kit neither installs a compiler nor +changes runtime search paths or the desktop environment. + +The recipe checks exact native package versions, compositor and compiler bytes, +GCC version/date and emitted ELF comment, the package-owned Hyprland header tree, +and matching shared-runtime bytes. The unchanged source requires Hyprland 0.56.2 +headers. CMake enables production input, disables experimental input and tracing, +and builds the bundled tests. Packaging runs CTest even with `--nocheck` or +`--repackage`; skipping makepkg integrity checks does not skip the recipe checks. + +## Qualify package transactions + +In a disposable matching Arch environment, with ordinary-user build tools and +previously authorized noninteractive sudo for isolated ALPM roots, run: + +```sh +python3 lifecycle.py --kit . \ + --revision 4b3396d9fe4bd3cf723b0eb8db83c18a8764b520 \ + --driver-version 0.24.0 --kit-sha256 REVIEWED_KIT_PROVENANCE_SHA256 \ + --output NEW_EVIDENCE_DIRECTORY +``` + +Add `--cxx /absolute/compiler/path` when needed. The runner validates the fresh +kit, builds the package, and checks its exact payload and provenance. It performs +install, remove, reinstall, and a paired dependency-refusal control in new +isolated ALPM roots. Dependency fixtures contain metadata only; native checks +are performed by the recipe. It does not alter the host package database or load +the module. A pass records kit/profile, package and payload hashes in `RESULT.json`. +Retain logs locally for review. Live activation, restart, upgrade, rollback and +native input qualification remain separate gates. + +## Check and activate an installed package + +Before installing, upgrading, rolling back, or removing the package, save work +and exit the Hyprland session. Use `pacman -U` from a text console with the exact +reviewed package file. Keep the prior package, matching dependencies and evidence +for rollback. Do not replace or unload the module inside a running compositor. + +Installation includes the module, license, source/build/kit/profile provenance, +and the optional verifier. It has no hooks, autoload or configuration edits. +With Python 3.11+, binutils (`readelf`) and the system `ldd` available, check the +installed package using the previously reviewed kit-provenance digest: + +```sh +python3 /usr/share/cua-hyprland-plugin/profile_verify.py \ + --kit /usr/share/cua-hyprland-plugin \ + --kit-sha256 REVIEWED_KIT_PROVENANCE_SHA256 \ + --consumer /usr/lib/cua/hyprland/cua-hyprland-plugin.so +``` + +This check needs no compiler, headers, or pkg-config. It verifies the reviewed +profile/tooling identity, module and build provenance, exact installed ABI package +versions, compositor bytes/compiler comment, and compositor/module shared-runtime +bytes. It does not prove that a running compositor mapped these bytes or that +input works. Run it before deliberate activation in a fresh session. Follow the +separately qualified activation procedure and verify application results. + +When ABI dependencies change, obtain a matching qualified package. If none is +available, exit the graphical session, remove the optional plugin package, +update the desktop and verify a fresh session. Disabling input alone does not +remove package dependencies. Rollback requires the matching saved package and +native environment followed by a fresh compositor session. diff --git a/libs/cua-driver/hyprland-plugin/packaging/release/README.md b/libs/cua-driver/hyprland-plugin/packaging/release/README.md index 1a63a46c95..4de8b6fa51 100644 --- a/libs/cua-driver/hyprland-plugin/packaging/release/README.md +++ b/libs/cua-driver/hyprland-plugin/packaging/release/README.md @@ -1,5 +1,59 @@ # Pinned source release +For a separately reviewed native profile around the unchanged Driver 0.24.0 +archive, see [profile-based rebuilds](profile-contract.md) and +[profile kit usage](PROFILE-USAGE.md). This legacy generator remains unchanged. + +Prepare a profile kit only after committing the packaging tooling and reviewing +the measured profile: + +```sh +python3 libs/cua-driver/hyprland-plugin/packaging/release/profile_bundle.py \ + --repo . --tooling-revision FULL_TOOLING_COMMIT_SHA \ + --profile REVIEWED_PROFILE.json --source-archive ORIGINAL_SOURCE.tar.gz \ + --output NEW_OUTPUT_DIRECTORY +``` + +The executing generator/verifier must match that tooling commit. The generator +copies the supplied source archive byte-for-byte and emits one deterministic +kit archive plus its checksum. Its filename includes the original source +identity, profile ID, numeric kit version, full profile digest and tooling SHA. +It never publishes assets or overwrites an existing output directory. + +Profile schema 1 requires these fields; all digests are lowercase SHA-256: + +- `profile_id`: lowercase hyphen-separated identifier; `kit_version`: numeric + `major.minor.patch`; `package_release`: integer at least 2; `architecture`: + `x86_64`. +- `source`: `revision` = `4b3396d9fe4bd3cf723b0eb8db83c18a8764b520`, + `driver_version` = `0.24.0`, `archive_sha256` and `manifest_sha256` measured + from the reviewed original archive and its embedded `SOURCE-PROVENANCE.json`. +- `hyprland`: exact `package_version`, `header_version` = `0.56.2`, compositor + executable `sha256`, and `headers_sha256`. +- `compiler`: full GCC `version` including date, exact `comment` (the string + `GCC: (GNU) ` followed by that version), and executable `sha256`. +- `runtime`: resolved `basename`, file `sha256`, and `packages`, a mapping of + relevant installed ABI package names (for example `gcc-libs`, `libstdc++`, + `hyprutils` or `aquamarine`) to exact versions. Include only packages needed + by the measured selected ABI contract, including the shared-runtime owner; + do not copy an entire OS package list. `hyprland` is modeled separately. + Python and binutils are consumer-tool dependencies supplied by the recipe. + +`headers_sha256` hashes the canonical JSON mapping of every package-owned file +under `/usr/include/hyprland/` from relative path to file SHA-256, serialized +with `json.dumps(mapping, sort_keys=True, indent=2) + "\n"`. The native verifier +also requires the on-disk header-tree file inventory to match `pacman -Qlq +hyprland` exactly, including plugin API headers. Symlinks are refused. +`profile_verify.header_inventory_sha256()` computes this inventory digest; it +does not accept or update a profile. + +Keep the profile bytes immutable once reviewed. Assign a new monotonically +increasing `package_release` for every distributed rebuild at Driver 0.24.0, +including a profile or tooling change; a kit label alone cannot enforce ALPM +ordering. The archive digest and full profile/tooling identifiers distinguish +candidate artifacts. Distribution owners must enforce unique package revisions +and bind published package bytes to their native qualification evidence. + This directory prepares a standalone Arch recipe and source archive for the production input candidate. It does not certify native behavior or publish assets. The existing `../arch/PKGBUILD` remains a discovery-only local recipe. diff --git a/libs/cua-driver/hyprland-plugin/packaging/release/lifecycle.py b/libs/cua-driver/hyprland-plugin/packaging/release/lifecycle.py index edd821ca8c..8acc93c400 100644 --- a/libs/cua-driver/hyprland-plugin/packaging/release/lifecycle.py +++ b/libs/cua-driver/hyprland-plugin/packaging/release/lifecycle.py @@ -3,6 +3,7 @@ import argparse import hashlib +import importlib.util import io import json import os @@ -20,6 +21,9 @@ BUILD = f"usr/share/{PACKAGE}/BUILD-PROVENANCE.json" LICENSE = f"usr/share/licenses/{PACKAGE}/LICENSE" PAYLOAD = {MODULE, SOURCE, BUILD, LICENSE} +PROFILE = f"usr/share/{PACKAGE}/PROFILE.json" +KIT = f"usr/share/{PACKAGE}/KIT-PROVENANCE.json" +VERIFIER = f"usr/share/{PACKAGE}/profile_verify.py" def require(condition, message): @@ -63,23 +67,62 @@ def verify_kit(kit, revision, driver_version): return manifest, checksums -def package_payload(package, manifest): +def verify_profile_kit(kit, revision, driver_version, kit_sha256): + require(re.fullmatch(r"[0-9a-f]{64}", kit_sha256), "requires reviewed kit provenance SHA-256") + provenance_path = kit / "KIT-PROVENANCE.json" + require(not provenance_path.is_symlink() and digest(provenance_path.read_bytes()) == kit_sha256, "kit provenance checksum mismatch") + provenance = json.loads(provenance_path.read_bytes()) + verifier_path = kit / "profile_verify.py" + require(not verifier_path.is_symlink() and digest(verifier_path.read_bytes()) == provenance["tooling_files"]["profile_verify.py"], "profile verifier checksum mismatch") + spec = importlib.util.spec_from_file_location("kit_profile_verify", verifier_path) + verifier = importlib.util.module_from_spec(spec) + # Execute only the checksum-verified helper without adding __pycache__ to + # the fresh kit whose exact inventory is checked immediately below. + exec(compile(verifier_path.read_bytes(), str(verifier_path), "exec"), verifier.__dict__) + profile, provenance = verifier.verify_kit(kit, kit_sha256, complete=True) + require(revision == profile["source"]["revision"] and driver_version == profile["source"]["driver_version"], "kit source revision/version mismatch") + expected = set(verifier.TOOLING) | {"PROFILE.json", "KIT-PROVENANCE.json", "SOURCE-PROVENANCE.json", "PKGBUILD", verifier.STEM + ".tar.gz"} + checksums = {} + for line in (kit / "SHA256SUMS").read_text().splitlines(): + checksum, name = line.split(" ") + require(name in expected and name not in checksums and re.fullmatch(r"[0-9a-f]{64}", checksum), "invalid profile kit checksum entry") + checksums[name] = checksum + require(set(checksums) == expected and {p.name for p in kit.iterdir()} == expected | {"SHA256SUMS"}, "use a fresh complete profile kit") + for name, checksum in checksums.items(): + path = kit / name + require(path.is_file() and not path.is_symlink() and digest(path.read_bytes()) == checksum, f"kit checksum mismatch: {name}") + require(digest(Path(__file__).read_bytes()) == provenance["tooling_files"]["lifecycle.py"], "runner differs from reviewed kit") + manifest = verifier.verify_archive(kit / (verifier.STEM + ".tar.gz"), profile) + require(verifier.source_manifest((kit / "SOURCE-PROVENANCE.json").read_bytes(), profile) == manifest, "kit historical manifest mismatch") + return manifest, checksums, profile, provenance + + +def package_payload(package, manifest, profile=None, kit_provenance=None): + allowed_payload = PAYLOAD | ({PROFILE, KIT, VERIFIER} if profile else set()) names = run(["bsdtar", "-tf", str(package)]).stdout.splitlines() files = [name for name in names if not name.endswith("/")] require(len(names) == len(set(names)), "duplicate package entries") - require(set(files) == PAYLOAD | {".PKGINFO", ".BUILDINFO", ".MTREE"}, "unexpected package payload or hooks") - directories = {str(parent) + "/" for name in PAYLOAD for parent in Path(name).parents if str(parent) != "."} + require(set(files) == allowed_payload | {".PKGINFO", ".BUILDINFO", ".MTREE"}, "unexpected package payload or hooks") + directories = {str(parent) + "/" for name in allowed_payload for parent in Path(name).parents if str(parent) != "."} require(set(names) - set(files) <= directories, "unexpected package directory") info = run(["bsdtar", "-xOf", str(package), ".PKGINFO"]).stdout.splitlines() require(f"pkgname = {PACKAGE}" in info, "package name mismatch") - require(f"pkgver = {manifest['driver_version']}-1" in info, "package version mismatch") + package_release = profile["package_release"] if profile else 1 + require(f"pkgver = {manifest['driver_version']}-{package_release}" in info, "package version mismatch") require("arch = x86_64" in info, "package architecture mismatch") - require({line for line in info if line.startswith("depend = ")} == - {"depend = hyprland=0.56.2-1", "depend = gcc-libs"}, "package dependency mismatch") - payload = {name: subprocess.check_output(["bsdtar", "-xOf", str(package), name]) for name in PAYLOAD} + dependencies = ({f"depend = hyprland={profile['hyprland']['package_version']}", "depend = python>=3.11", "depend = binutils"} | + {f"depend = {name}={version}" for name, version in profile["runtime"]["packages"].items()} + if profile else {"depend = hyprland=0.56.2-1", "depend = gcc-libs"}) + require({line for line in info if line.startswith("depend = ")} == dependencies, "package dependency mismatch") + payload = {name: subprocess.check_output(["bsdtar", "-xOf", str(package), name]) for name in allowed_payload} require(json.loads(payload[SOURCE]) == manifest, "packaged source provenance mismatch") require(digest(payload[LICENSE]) == manifest["files"]["LICENSE.md"], "packaged license provenance mismatch") build = json.loads(payload[BUILD]) + if profile: + require(json.loads(payload[PROFILE]) == profile and build["profile"] == profile, "packaged profile mismatch") + require(json.loads(payload[KIT]) == kit_provenance and build["kit"] == kit_provenance, "packaged kit mismatch") + require(digest(payload[VERIFIER]) == kit_provenance["tooling_files"]["profile_verify.py"], "packaged verifier mismatch") + require(build["compiler_runtime_sha256"] == profile["runtime"]["sha256"], "packaged profile runtime mismatch") require(build["source"] == manifest, "packaged build source mismatch") require(build["module_sha256"] == digest(payload[MODULE]), "packaged module hash mismatch") require(build["module_runtime_sha256"] == build["compiler_runtime_sha256"] == @@ -134,7 +177,7 @@ def assert_state(root, payload, installed): require(not path.exists() and not path.is_symlink(), f"removed payload remains: {name}") -def qualify(work, package, payload, manifest): +def qualify(work, package, payload, manifest, profile=None): log = [] def transaction(root, *arguments, check=True): @@ -146,18 +189,25 @@ def transaction(root, *arguments, check=True): require(not check or result.returncode == 0, "pacman failed; see retained transactions.json") return result - gcc = work / "gcc-libs-fixture.pkg.tar.gz" - dependency_fixture(gcc, "gcc-libs", "1-1") - for label, version in (("matching", "0.56.2-1"), ("mismatched", "0.56.2-2")): + runtime_packages = {**profile["runtime"]["packages"], "python": "3.11.0-1", "binutils": "1-1"} if profile else {"gcc-libs": "1-1"} + runtime_fixtures = [] + for name, version in runtime_packages.items(): + fixture = work / f"{name}-fixture.pkg.tar.gz" + dependency_fixture(fixture, name, version) + runtime_fixtures.append(str(fixture)) + matching_version = profile["hyprland"]["package_version"] if profile else "0.56.2-1" + mismatched_version = matching_version + ".1" if profile else "0.56.2-2" + package_release = profile["package_release"] if profile else 1 + for label, version in (("matching", matching_version), ("mismatched", mismatched_version)): root = new_root(work, label) hyprland = work / f"hyprland-{label}-fixture.pkg.tar.gz" dependency_fixture(hyprland, "hyprland", version) - transaction(root, "-U", str(gcc), str(hyprland)) + transaction(root, "-U", *runtime_fixtures, str(hyprland)) if label == "mismatched": result = transaction(root, "-U", str(package), check=False) diagnostic = result.stdout + result.stderr require(result.returncode != 0 and 'unable to satisfy dependency' in diagnostic and - 'hyprland=0.56.2-1' in diagnostic, "missing specific Hyprland dependency refusal") + f'hyprland={matching_version}' in diagnostic, "missing specific Hyprland dependency refusal") assert_state(root, payload, False) require(transaction(root, "-Q", PACKAGE, check=False).returncode != 0, "rejected package registered in ALPM") @@ -169,7 +219,7 @@ def transaction(root, *arguments, check=True): result = transaction(root, "-Q", PACKAGE, check=False) if installed: require(result.returncode == 0 and result.stdout.strip() == - f"{PACKAGE} {manifest['driver_version']}-1", "installed ALPM identity mismatch") + f"{PACKAGE} {manifest['driver_version']}-{package_release}", "installed ALPM identity mismatch") else: require(result.returncode != 0, "removed package registered in ALPM") @@ -179,6 +229,7 @@ def main(): parser.add_argument("--kit", type=Path, required=True, help="fresh standalone development or release kit") parser.add_argument("--revision", required=True) parser.add_argument("--driver-version", required=True) + parser.add_argument("--kit-sha256", help="reviewed KIT-PROVENANCE.json digest; required for profile kits") parser.add_argument("--cxx", type=Path, default=Path("/usr/bin/g++")) parser.add_argument("--output", type=Path, required=True, help="new retained evidence/build directory") args = parser.parse_args() @@ -187,7 +238,12 @@ def main(): require(os.geteuid() != 0, "run as an ordinary build user; only isolated pacman uses sudo") require(args.cxx.is_absolute() and args.cxx.is_file(), "requires an absolute compiler path") kit = args.kit.resolve(strict=True) - manifest, checksums = verify_kit(kit, args.revision, args.driver_version) + profile = kit_provenance = None + if args.kit_sha256: + manifest, checksums, profile, kit_provenance = verify_profile_kit(kit, args.revision, args.driver_version, args.kit_sha256) + else: + require(not (kit / "KIT-PROVENANCE.json").exists(), "profile kit requires --kit-sha256") + manifest, checksums = verify_kit(kit, args.revision, args.driver_version) work = args.output.absolute() work.mkdir(parents=True, exist_ok=False) work = work.resolve(strict=True) @@ -209,8 +265,12 @@ def main(): packages = [p for p in packages if not p.name.endswith(".sig")] require(len(packages) == 1, "expected one built plugin package") package = packages[0] - payload = package_payload(package, manifest) - qualify(work, package, payload, manifest) + if profile: + payload = package_payload(package, manifest, profile, kit_provenance) + qualify(work, package, payload, manifest, profile) + else: + payload = package_payload(package, manifest) + qualify(work, package, payload, manifest) evidence = {"schema": 1, "result": "passed", "scope": "native build and isolated ALPM lifecycle", "source_revision": args.revision, "driver_version": args.driver_version, "plugin_version": manifest["plugin_version"], "kit_sha256": checksums, @@ -218,10 +278,12 @@ def main(): "dependency_fixtures": "metadata only; ABI verified by the native recipe", "live_restart_verified": False, "live_rollback_verified": False, "published_release_verified": False} + if profile: + evidence.update(profile=profile, kit=kit_provenance, kit_provenance_sha256=args.kit_sha256) (work / "RESULT.json").write_text(json.dumps(evidence, indent=2, sort_keys=True) + "\n") print("Passed: native build and isolated ALPM install/remove/reinstall/dependency refusal.") print("Live restart, rollback, and published release verification remain separate gates.") - except (ValueError, KeyError, OSError, subprocess.CalledProcessError) as error: + except (ValueError, KeyError, TypeError, OSError, tarfile.TarError, subprocess.CalledProcessError) as error: parser.exit(1, f"error: {error}\n") diff --git a/libs/cua-driver/hyprland-plugin/packaging/release/profile-contract.md b/libs/cua-driver/hyprland-plugin/packaging/release/profile-contract.md index 8255f76bb0..a5a9b1352e 100644 --- a/libs/cua-driver/hyprland-plugin/packaging/release/profile-contract.md +++ b/libs/cua-driver/hyprland-plugin/packaging/release/profile-contract.md @@ -39,7 +39,7 @@ unsupported until separately qualified. Do not silently reduce the two-lane scope or widen production admission to satisfy a test fixture. Use the complete native Linux runner selected in the -[test-harnesses guide](../../../../docs/test-harnesses-guide.md), plus bounded +[test-harnesses guide](../../../docs/test-harnesses-guide.md), plus bounded application, two-lane overlap, third-owner refusal, primary-input isolation, conflict, stale-target/geometry, cancellation, and cleanup evidence. Preserve the canonical runner's assertions. Instrumented diagnostics and the shipped diff --git a/libs/cua-driver/hyprland-plugin/packaging/release/profile_bundle.py b/libs/cua-driver/hyprland-plugin/packaging/release/profile_bundle.py new file mode 100644 index 0000000000..88c6bb730c --- /dev/null +++ b/libs/cua-driver/hyprland-plugin/packaging/release/profile_bundle.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Wrap unchanged Driver 0.24.0 source in a separately reviewed native-profile kit.""" + +import argparse +import gzip +import io +from pathlib import Path +import re +import subprocess +import tarfile + +import profile_verify as verify + +RELEASE = "libs/cua-driver/hyprland-plugin/packaging/release/" + + +def committed_file(repo, revision, name): + path = RELEASE + name + entry = subprocess.check_output(["git", "-C", str(repo), "ls-tree", revision, "--", path], text=True) + verify.require(entry.startswith(("100644 blob ", "100755 blob ")), f"missing committed tooling file: {name}") + return subprocess.check_output(["git", "-C", str(repo), "show", f"{revision}:{path}"]) + + +def deterministic_archive(payload): + raw = io.BytesIO() + with tarfile.open(fileobj=raw, mode="w", format=tarfile.USTAR_FORMAT) as archive: + for name, data in sorted(payload.items()): + info = tarfile.TarInfo(name) + info.size, info.mode, info.mtime = len(data), 0o644, 0 + archive.addfile(info, io.BytesIO(data)) + compressed = io.BytesIO() + with gzip.GzipFile(filename="", fileobj=compressed, mode="wb", mtime=0, compresslevel=9) as archive: + archive.write(raw.getvalue()) + return compressed.getvalue() + + +def generate(repo, tooling_revision, profile_path, source_archive, output): + verify.require(re.fullmatch(r"[0-9a-f]{40}", tooling_revision), "requires a full tooling commit SHA") + resolved = subprocess.check_output(["git", "-C", str(repo), "rev-parse", f"{tooling_revision}^{{commit}}"], text=True).strip() + verify.require(resolved == tooling_revision, "tooling revision must name a commit") + payload = {name: committed_file(repo, tooling_revision, name) for name in verify.TOOLING} + # Do not validate with dirty or differently versioned generator/verifier code. + for name in ("profile_bundle.py", "profile_verify.py"): + verify.require(payload[name] == (Path(__file__).parent / name).read_bytes(), f"executing tooling differs from commit: {name}") + verify.require(profile_path.is_file() and not profile_path.is_symlink(), "profile must be an explicit regular file") + profile_data = profile_path.read_bytes() + profile = verify.validate_profile(verify.read_json(profile_data)) + manifest = verify.verify_archive(source_archive, profile) + provenance = {"schema": 1, "tooling_revision": tooling_revision, "profile_sha256": verify.sha256(profile_data), + "source": profile["source"], "cmake_options": verify.OPTIONS, "native_certified": False, + "tooling_files": {name: verify.sha256(data) for name, data in payload.items()}} + payload["PROFILE.json"] = profile_data + payload["KIT-PROVENANCE.json"] = verify.json_bytes(provenance) + payload["SOURCE-PROVENANCE.json"] = verify.json_bytes(manifest) + # Preserve original manifest bytes too, even if its JSON formatting differs. + with tarfile.open(source_archive, "r:gz") as archive: + payload["SOURCE-PROVENANCE.json"] = archive.extractfile(verify.STEM + "/SOURCE-PROVENANCE.json").read() + payload[verify.STEM + ".tar.gz"] = source_archive.read_bytes() + verify.require(verify.sha256(payload[verify.STEM + ".tar.gz"]) == profile["source"]["archive_sha256"], "source archive changed during generation") + verify.source_manifest(payload["SOURCE-PROVENANCE.json"], profile) + payload["PKGBUILD"] = verify.render_recipe(payload["PROFILE-PKGBUILD.in"].decode(), profile, provenance) + payload["SHA256SUMS"] = "".join(f"{verify.sha256(data)} {name}\n" for name, data in sorted(payload.items())).encode() + # The archive identity binds profile bytes and tooling commit, not just labels. + name = (f"{verify.STEM}-profile-{profile['profile_id']}-kit-{profile['kit_version']}" + f"-{provenance['profile_sha256']}-{tooling_revision}.tar.gz") + archive_data = deterministic_archive(payload) + output.mkdir(parents=True, exist_ok=False) + (output / name).write_bytes(archive_data) + (output / (name + ".sha256")).write_text(f"{verify.sha256(archive_data)} {name}\n") + return provenance + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo", required=True, type=Path) + parser.add_argument("--tooling-revision", required=True) + parser.add_argument("--profile", required=True, type=Path) + parser.add_argument("--source-archive", required=True, type=Path) + parser.add_argument("--output", required=True, type=Path, help="new output directory") + args = parser.parse_args() + try: + generate(args.repo.resolve(strict=True), args.tooling_revision, args.profile, args.source_archive, args.output) + except (ValueError, KeyError, TypeError, OSError, tarfile.TarError, subprocess.CalledProcessError) as error: + parser.exit(1, f"error: {error}\n") + + +if __name__ == "__main__": + main() diff --git a/libs/cua-driver/hyprland-plugin/packaging/release/profile_verify.py b/libs/cua-driver/hyprland-plugin/packaging/release/profile_verify.py new file mode 100644 index 0000000000..02671b73c1 --- /dev/null +++ b/libs/cua-driver/hyprland-plugin/packaging/release/profile_verify.py @@ -0,0 +1,322 @@ +#!/usr/bin/env python3 +"""Verify a reviewed native profile against immutable source or an installed module.""" + +import argparse +import hashlib +import json +import os +from pathlib import Path, PurePosixPath +import platform +import re +import subprocess +import tarfile +import tempfile + +SOURCE_REVISION = "4b3396d9fe4bd3cf723b0eb8db83c18a8764b520" +DRIVER_VERSION = "0.24.0" +STEM = f"cua-hyprland-plugin-{DRIVER_VERSION}-{SOURCE_REVISION}" +OPTIONS = {"CUA_HYPRLAND_INPUT": "ON", "CUA_HYPRLAND_TEST_INPUT": "OFF", "CUA_HYPRLAND_INPUT_TRACE": "OFF"} +TOOLING = ("profile_bundle.py", "profile_verify.py", "PROFILE-PKGBUILD.in", "PROFILE-USAGE.md", "lifecycle.py") + + +def require(condition, message): + if not condition: + raise ValueError(message) + + +def sha256(data): + return hashlib.sha256(data).hexdigest() + + +def digest(path): + return sha256(path.read_bytes()) + + +def json_bytes(value): + return (json.dumps(value, sort_keys=True, indent=2) + "\n").encode() + + +def read_json(data): + def unique(pairs): + result = {} + for key, value in pairs: + require(key not in result, f"duplicate JSON key: {key}") + result[key] = value + return result + return json.loads(data, object_pairs_hook=unique) + + +def keys(value, expected, label): + require(isinstance(value, dict) and set(value) == set(expected.split()), f"invalid {label} fields") + + +def hash_value(value): + require(isinstance(value, str) and re.fullmatch(r"[0-9a-f]{64}", value), "requires a lowercase SHA-256") + + +def validate_profile(profile): + keys(profile, "schema profile_id kit_version package_release source architecture hyprland compiler runtime", "profile") + require(type(profile["schema"]) is int and profile["schema"] == 1, "unsupported profile schema") + require(len(profile["profile_id"]) <= 32 and re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", profile["profile_id"]), "invalid profile ID") + require(len(profile["kit_version"]) <= 20 and re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", profile["kit_version"]), "invalid kit version") + require(type(profile["package_release"]) is int and profile["package_release"] >= 2, "profile package release must be >=2") + require(profile["architecture"] == "x86_64", "only x86_64 is supported") + source = profile["source"] + keys(source, "revision driver_version archive_sha256 manifest_sha256", "source") + require(source["revision"] == SOURCE_REVISION and source["driver_version"] == DRIVER_VERSION, "requires the original Driver 0.24.0 source") + hash_value(source["archive_sha256"]) + hash_value(source["manifest_sha256"]) + hyprland = profile["hyprland"] + keys(hyprland, "package_version header_version headers_sha256 sha256", "Hyprland") + require(hyprland["header_version"] == "0.56.2", "unchanged source requires Hyprland 0.56.2 headers") + require(re.fullmatch(r"0\.56\.2-[0-9]+(?:\.[0-9]+)?", hyprland["package_version"]), "invalid Hyprland package version") + hash_value(hyprland["sha256"]) + hash_value(hyprland["headers_sha256"]) + compiler = profile["compiler"] + keys(compiler, "version comment sha256", "compiler") + require(re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+ [0-9]{8}", compiler["version"]), "requires exact GCC version/date") + require(compiler["comment"] == "GCC: (GNU) " + compiler["version"], "invalid compiler ELF comment") + hash_value(compiler["sha256"]) + runtime = profile["runtime"] + keys(runtime, "basename sha256 packages", "runtime") + require(re.fullmatch(r"libstdc\+\+\.so\.6\.0\.[0-9]+", runtime["basename"]), "invalid shared runtime basename") + hash_value(runtime["sha256"]) + packages = runtime["packages"] + require(isinstance(packages, dict) and packages, "requires reviewed ABI runtime packages") + for name in packages: + require(re.fullmatch(r"[a-z0-9][a-z0-9@._+-]*", name) and name not in {"hyprland", "python", "binutils"}, "invalid ABI package name") + for version in packages.values(): + require(isinstance(version, str) and re.fullmatch(r"[0-9][0-9A-Za-z.:+_~-]*-[0-9]+(?:\.[0-9]+)?", version), "invalid runtime package version") + return profile + + +def source_manifest(data, profile): + require(sha256(data) == profile["source"]["manifest_sha256"], "historical manifest checksum mismatch") + manifest = read_json(data) + expected = {"schema": 1, "source_revision": SOURCE_REVISION, "driver_version": DRIVER_VERSION, + "release_tag": "cua-driver-rs-v0.24.0", "plugin_version": "0.1.0", + "architecture": "x86_64", "native_certified": False, "cmake_options": OPTIONS, + "hyprland_version": "0.56.2", "hyprland_package": "0.56.2-1", + "compiler_version": "16.1.1 20260728", "compiler_comment": "GCC: (GNU) 16.1.1 20260728"} + require(set(manifest) == set(expected) | {"files"}, "invalid historical manifest fields") + for key, value in expected.items(): + require(manifest[key] == value, f"historical source provenance mismatch: {key}") + require(isinstance(manifest["files"], dict) and {"CMakeLists.txt", "LICENSE.md", "verify.py"} <= set(manifest["files"]), "invalid source inventory") + for name, checksum in manifest["files"].items(): + path = PurePosixPath(name) + require(not path.is_absolute() and path.as_posix() == name and ".." not in path.parts and "\\" not in name and name != "SOURCE-PROVENANCE.json", "unsafe source inventory path") + hash_value(checksum) + return manifest + + +def verify_archive(archive, profile): + require(archive.is_file() and not archive.is_symlink(), "source archive must be a regular file") + require(digest(archive) == profile["source"]["archive_sha256"], "source archive checksum mismatch") + payload = {} + with tarfile.open(archive, "r:gz") as contents: + for member in contents: + require(member.isfile() and member.name.startswith(STEM + "/"), "invalid source archive member") + name = member.name[len(STEM) + 1:] + path = PurePosixPath(name) + require(name and path.as_posix() == name and not path.is_absolute() and ".." not in path.parts and "\\" not in name, "unsafe source archive path") + require(name not in payload, "duplicate source archive member") + payload[name] = contents.extractfile(member).read() + require("SOURCE-PROVENANCE.json" in payload, "missing source manifest") + manifest = source_manifest(payload["SOURCE-PROVENANCE.json"], profile) + require(set(payload) == set(manifest["files"]) | {"SOURCE-PROVENANCE.json"}, "source archive inventory mismatch") + for name, checksum in manifest["files"].items(): + require(sha256(payload[name]) == checksum, f"source archive content mismatch: {name}") + return manifest + + +def verify_source(source, profile): + require(source.is_dir() and not source.is_symlink(), "source must be a real directory") + manifest = source_manifest((source / "SOURCE-PROVENANCE.json").read_bytes(), profile) + actual = set() + for path in source.rglob("*"): + require(not path.is_symlink() and (path.is_dir() or path.is_file()), "source contains a nonregular entry") + if path.is_file(): + actual.add(path.relative_to(source).as_posix()) + require(actual == set(manifest["files"]) | {"SOURCE-PROVENANCE.json"}, "source file inventory mismatch") + for name, checksum in manifest["files"].items(): + require(digest(source / name) == checksum, f"source checksum mismatch: {name}") + require(f"project(cua_hyprland_plugin VERSION {manifest['plugin_version']} LANGUAGES CXX)" in (source / "CMakeLists.txt").read_text(), "plugin version mismatch") + return manifest + + +def verify_kit(kit, expected_sha, *, complete=False): + hash_value(expected_sha) + provenance_path = kit / "KIT-PROVENANCE.json" + require(provenance_path.is_file() and not provenance_path.is_symlink() and digest(provenance_path) == expected_sha, "kit provenance checksum mismatch") + provenance = read_json(provenance_path.read_bytes()) + keys(provenance, "schema tooling_revision profile_sha256 source cmake_options native_certified tooling_files", "kit provenance") + require(provenance["schema"] == 1 and provenance["native_certified"] is False and provenance["cmake_options"] == OPTIONS, "invalid kit contract") + require(re.fullmatch(r"[0-9a-f]{40}", provenance["tooling_revision"]), "invalid tooling revision") + require(set(provenance["tooling_files"]) == set(TOOLING), "tooling inventory mismatch") + for checksum in provenance["tooling_files"].values(): + hash_value(checksum) + profile_path = kit / "PROFILE.json" + require(profile_path.is_file() and not profile_path.is_symlink() and digest(profile_path) == provenance["profile_sha256"], "profile checksum mismatch") + profile = validate_profile(read_json(profile_path.read_bytes())) + require(provenance["source"] == profile["source"], "kit source identity mismatch") + required = TOOLING if complete else ("profile_verify.py",) + for name in required: + path = kit / name + require(path.is_file() and not path.is_symlink() and digest(path) == provenance["tooling_files"][name], f"tooling checksum mismatch: {name}") + require(digest(Path(__file__)) == provenance["tooling_files"]["profile_verify.py"], "executing verifier differs from reviewed kit") + if complete: + expected_recipe = render_recipe((kit / "PROFILE-PKGBUILD.in").read_text(), profile, provenance) + require((kit / "PKGBUILD").read_bytes() == expected_recipe, "recipe differs from reviewed tooling/profile") + return profile, provenance + + +def render_recipe(template, profile, provenance): + replacements = {"DRIVER_VERSION": DRIVER_VERSION, "PKGREL": str(profile["package_release"]), + "PROFILE_ID": profile["profile_id"], "STEM": STEM, + "HYPRLAND_PACKAGE": profile["hyprland"]["package_version"], + "RUNTIME_DEPENDS": " ".join(f"'{name}={version}'" for name, version in sorted(profile["runtime"]["packages"].items())), + "ARCHIVE_SHA256": profile["source"]["archive_sha256"], + "PROFILE_SHA256": provenance["profile_sha256"], + "KIT_SHA256": sha256(json_bytes(provenance)), + "VERIFIER_SHA256": provenance["tooling_files"]["profile_verify.py"]} + for key, value in replacements.items(): + template = template.replace(f"@{key}@", value) + require(not re.search(r"@[A-Z_]+@", template), "unresolved recipe placeholder") + return template.encode() + + +def run(*command, input=None): + return subprocess.check_output(command, input=input, text=True, stderr=subprocess.PIPE, + env={**os.environ, "LC_ALL": "C"}).strip() + + +def elf_comment(binary, expected): + lines = run("readelf", "-p", ".comment", str(binary)).splitlines() + entries = [re.sub(r"^\s*\[[^]]+\]\s*", "", line).strip() for line in lines] + require(expected in entries, "ELF compiler comment mismatch: " + binary.name) + + +def linked_runtime(binary, profile): + dynamic = run("readelf", "-d", str(binary)) + require("Shared library: [libstdc++.so.6]" in dynamic and "Shared library: [libc++.so" not in dynamic, "binary must use shared libstdc++") + matches = re.findall(r"^\s*libstdc\+\+\.so\.6 => (/\S+) \(", run("ldd", str(binary)), re.MULTILINE) + require(len(matches) == 1, "cannot resolve shared libstdc++") + runtime = Path(matches[0]).resolve(strict=True) + require(runtime.name == profile["runtime"]["basename"] and digest(runtime) == profile["runtime"]["sha256"], "loaded shared runtime mismatch") + require(run("pacman", "-Qoq", str(runtime)) in profile["runtime"]["packages"], "shared runtime owner is not pinned by profile") + return digest(runtime) + + +def verify_environment(profile): + require(platform.system() == "Linux" and platform.machine() == profile["architecture"], "requires Linux x86_64") + packages = {"hyprland": profile["hyprland"]["package_version"], **profile["runtime"]["packages"]} + for name, version in packages.items(): + require(run("pacman", "-Q", name) == f"{name} {version}", f"native package mismatch: {name}") + compositor = Path("/usr/bin/Hyprland") + require(digest(compositor) == profile["hyprland"]["sha256"], "compositor checksum mismatch") + elf_comment(compositor, profile["compiler"]["comment"]) + return linked_runtime(compositor, profile) + + +def verify_native(cxx, profile): + runtime_sha = verify_environment(profile) + require(cxx.is_absolute() and cxx.is_file(), "C++ compiler must be an existing absolute path") + require(digest(cxx) == profile["compiler"]["sha256"], "compiler checksum mismatch") + require(run("pkg-config", "--modversion", "hyprland") == profile["hyprland"]["header_version"], "Hyprland header mismatch") + require(header_inventory_sha256() == profile["hyprland"]["headers_sha256"], "Hyprland header inventory mismatch") + macros = run(str(cxx), "-dM", "-E", "-x", "c++", "-", input="") + require(f'#define __VERSION__ "{profile["compiler"]["version"]}"' in macros.splitlines() and not re.search(r"^#define __clang__\b", macros, re.MULTILINE), "GCC version/date mismatch") + with tempfile.TemporaryDirectory(prefix="cua-profile-probe-") as temporary: + probe = Path(temporary) / "probe.o" + run(str(cxx), "-x", "c++", "-c", "-o", str(probe), "-", input="int cua_compiler_probe;\n") + elf_comment(probe, profile["compiler"]["comment"]) + runtime = Path(run(str(cxx), "--print-file-name=libstdc++.so.6")).resolve(strict=True) + require(runtime.name == profile["runtime"]["basename"] and digest(runtime) == runtime_sha, "compiler shared runtime mismatch") + return {"compiler_sha256": digest(cxx), "compiler_version": profile["compiler"]["version"], + "compiler_probe_comment": profile["compiler"]["comment"], "compiler_runtime_sha256": runtime_sha, + "compositor_sha256": profile["hyprland"]["sha256"], "compositor_runtime_sha256": runtime_sha} + + +def header_inventory_sha256(root=Path("/usr/include/hyprland")): + require(root.is_dir() and not root.is_symlink(), "missing canonical Hyprland headers") + prefix = str(root) + "/" + packaged = {name[len(prefix):] for name in run("pacman", "-Qlq", "hyprland").splitlines() + if name.startswith(prefix) and not name.endswith("/")} + actual = {} + for path in root.rglob("*"): + require(not path.is_symlink() and (path.is_file() or path.is_dir()), "nonregular Hyprland header entry") + if path.is_file(): + actual[path.relative_to(root).as_posix()] = digest(path) + require(actual and set(actual) == packaged, "Hyprland package header inventory mismatch") + return sha256(json_bytes(actual)) + + +def verify_build(build, source, cxx, profile): + cache = {} + for line in (build / "CMakeCache.txt").read_text().splitlines(): + match = re.match(r"([^:#/][^:]*):[^=]+=(.*)", line) + if match: + require(match[1] not in cache, "duplicate CMake cache entry") + cache[match[1]] = match[2] + expected = dict(OPTIONS, BUILD_TESTING="ON", CUA_HYPRLAND_BUILD_PLUGIN="ON", CMAKE_BUILD_TYPE="Release", + CMAKE_GENERATOR="Ninja", + CUA_HYPRLAND_EXPECTED_VERSION=profile["hyprland"]["header_version"], + CUA_HYPRLAND_TEST_OPERATOR_KEY="", CMAKE_CXX_COMPILER=str(cxx), + CMAKE_HOME_DIRECTORY=str(source.resolve())) + for name, value in expected.items(): + require(cache.get(name) == value, f"build configuration mismatch: {name}") + module = build / "cua-hyprland-plugin.so" + elf_comment(module, profile["compiler"]["comment"]) + linked_runtime(module, profile) + return digest(module) + + +def verify_consumer(module, kit, profile, provenance): + build = read_json((kit / "BUILD-PROVENANCE.json").read_bytes()) + manifest = source_manifest((kit / "SOURCE-PROVENANCE.json").read_bytes(), profile) + require(build["source"] == manifest and build["profile"] == profile and build["kit"] == provenance, "installed provenance identity mismatch") + require(module.is_file() and not module.is_symlink() and digest(module) == build["module_sha256"], "installed module checksum mismatch") + require(build["compiler_sha256"] == profile["compiler"]["sha256"] and build["compiler_version"] == profile["compiler"]["version"] and build["compiler_probe_comment"] == profile["compiler"]["comment"], "installed compiler provenance mismatch") + require(build["compositor_sha256"] == profile["hyprland"]["sha256"], "installed compositor provenance mismatch") + runtime_sha = profile["runtime"]["sha256"] + require(all(build[key] == runtime_sha for key in ("compiler_runtime_sha256", "compositor_runtime_sha256", "module_runtime_sha256")), "installed runtime provenance mismatch") + verify_environment(profile) + elf_comment(module, profile["compiler"]["comment"]) + linked_runtime(module, profile) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--kit", required=True, type=Path) + parser.add_argument("--kit-sha256", required=True, help="reviewed KIT-PROVENANCE.json SHA-256") + parser.add_argument("--source", type=Path) + parser.add_argument("--archive", type=Path) + parser.add_argument("--cxx", type=Path) + parser.add_argument("--build", type=Path) + parser.add_argument("--output", type=Path) + parser.add_argument("--consumer", type=Path, help="installed module; requires no compiler or headers") + args = parser.parse_args() + try: + profile, provenance = verify_kit(args.kit, args.kit_sha256) + if args.consumer: + require(not any((args.source, args.archive, args.cxx, args.build, args.output)), "consumer mode cannot take build inputs") + verify_consumer(args.consumer, args.kit, profile, provenance) + print("Passed installed profile compatibility checks; live activation remains separate.") + return + require(args.source and args.archive and args.cxx, "build verification requires --source, --archive and --cxx") + archive_manifest = verify_archive(args.archive, profile) + manifest = verify_source(args.source, profile) + require(manifest == archive_manifest, "extracted source differs from archive") + native = verify_native(args.cxx, profile) + if args.build: + native["module_sha256"] = verify_build(args.build, args.source, args.cxx, profile) + native["module_runtime_sha256"] = profile["runtime"]["sha256"] + if args.output: + require(args.build is not None, "build evidence is required for output") + args.output.write_bytes(json_bytes(dict(native, source=manifest, profile=profile, kit=provenance))) + except (ValueError, KeyError, TypeError, OSError, tarfile.TarError, subprocess.CalledProcessError) as error: + parser.exit(1, f"error: {error}\n") + + +if __name__ == "__main__": + main() diff --git a/libs/cua-driver/hyprland-plugin/packaging/release/test_profile_release.py b/libs/cua-driver/hyprland-plugin/packaging/release/test_profile_release.py new file mode 100644 index 0000000000..40269b8b16 --- /dev/null +++ b/libs/cua-driver/hyprland-plugin/packaging/release/test_profile_release.py @@ -0,0 +1,427 @@ +"""Profile packaging contracts, with synthetic archives and native command responses.""" + +import copy +import io +import json +import os +from pathlib import Path +import subprocess +import tarfile +import tempfile +import unittest +from unittest import mock + +import profile_bundle as bundle +import profile_verify as verify +import lifecycle + +HERE = Path(__file__).resolve().parent + + +class ProfileTest(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix="cua-profile-test-") + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.tooling_sha = "b" * 40 + self.files = {"CMakeLists.txt": b"project(cua_hyprland_plugin VERSION 0.1.0 LANGUAGES CXX)\n", + "LICENSE.md": b"Synthetic license\n", "verify.py": b"raise SystemExit('historical verifier must never execute')\n", + "src/plugin.cpp": b"// synthetic source\n"} + self.manifest = {"schema": 1, "source_revision": verify.SOURCE_REVISION, "driver_version": "0.24.0", + "release_tag": "cua-driver-rs-v0.24.0", "plugin_version": "0.1.0", "architecture": "x86_64", + "native_certified": False, "cmake_options": verify.OPTIONS, "hyprland_version": "0.56.2", + "hyprland_package": "0.56.2-1", "compiler_version": "16.1.1 20260728", + "compiler_comment": "GCC: (GNU) 16.1.1 20260728", + "files": {name: verify.sha256(data) for name, data in self.files.items()}} + self.files["SOURCE-PROVENANCE.json"] = verify.json_bytes(self.manifest) + self.archive = self.root / (verify.STEM + ".tar.gz") + self.archive.write_bytes(bundle.deterministic_archive({verify.STEM + "/" + name: data for name, data in self.files.items()})) + self.profile = {"schema": 1, "profile_id": "synthetic-native", "kit_version": "1.0.0", "package_release": 2, + "architecture": "x86_64", "source": {"revision": verify.SOURCE_REVISION, "driver_version": "0.24.0", + "archive_sha256": verify.digest(self.archive), "manifest_sha256": verify.sha256(self.files["SOURCE-PROVENANCE.json"])}, + "hyprland": {"package_version": "0.56.2-2", "header_version": "0.56.2", "headers_sha256": "d" * 64, "sha256": "a" * 64}, + "compiler": {"version": "16.2.1 20260810", "comment": "GCC: (GNU) 16.2.1 20260810", "sha256": "b" * 64}, + "runtime": {"basename": "libstdc++.so.6.0.99", "sha256": "c" * 64, "packages": {"gcc-libs": "16.2.1-1"}}} + self.profile_path = self.root / "reviewed.json" + self.profile_path.write_bytes(verify.json_bytes(self.profile)) + + def generate(self, name="output"): + output = self.root / name + with mock.patch.object(bundle, "committed_file", side_effect=lambda repo, sha, name: (HERE / name).read_bytes()) as committed, mock.patch.object(bundle.subprocess, "check_output", return_value=self.tooling_sha + "\n"): + metadata = bundle.generate(self.root, self.tooling_sha, self.profile_path, self.archive, output) + self.assertTrue(all(call.args[1] == self.tooling_sha for call in committed.call_args_list)) + archive = next(output.glob("*.tar.gz")) + kit = self.root / (name + "-kit") + kit.mkdir() + with tarfile.open(archive) as contents: + contents.extractall(kit, filter="data") + return output, kit, metadata + + def source(self): + source = self.root / "source" + source.mkdir() + for name, data in self.files.items(): + path = source / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + return source + + def test_deterministic_separate_kit_and_unchanged_archive(self): + output, kit, metadata = self.generate() + second, _, _ = self.generate("second") + self.assertEqual({p.name: p.read_bytes() for p in output.iterdir()}, {p.name: p.read_bytes() for p in second.iterdir()}) + self.assertEqual((kit / self.archive.name).read_bytes(), self.archive.read_bytes()) + self.assertEqual((kit / "SOURCE-PROVENANCE.json").read_bytes(), self.files["SOURCE-PROVENANCE.json"]) + self.assertFalse(metadata["native_certified"]) + self.assertEqual(metadata["source"]["revision"], verify.SOURCE_REVISION) + self.assertNotEqual(metadata["source"]["revision"], metadata["tooling_revision"]) + verify.verify_kit(kit, verify.digest(kit / "KIT-PROVENANCE.json"), complete=True) + subprocess.run(["bash", "-n", str(kit / "PKGBUILD")], check=True) + subprocess.run(["shasum", "-a", "256", "-c", "SHA256SUMS"], cwd=kit, capture_output=True, check=True) + + def test_profile_change_changes_kit_but_not_source(self): + first, kit, _ = self.generate() + self.profile["package_release"] = 3 + self.profile_path.write_bytes(verify.json_bytes(self.profile)) + second, other, _ = self.generate("other") + self.assertNotEqual(next(first.glob("*.tar.gz")).name, next(second.glob("*.tar.gz")).name) + self.assertEqual((kit / self.archive.name).read_bytes(), (other / self.archive.name).read_bytes()) + + def test_generation_refuses_wrong_source_dirty_tooling_and_existing_output(self): + self.generate() + with mock.patch.object(bundle, "committed_file", side_effect=lambda repo, sha, name: (HERE / name).read_bytes()), mock.patch.object(bundle.subprocess, "check_output", return_value=self.tooling_sha + "\n"): + with self.assertRaises(FileExistsError): + bundle.generate(self.root, self.tooling_sha, self.profile_path, self.archive, self.root / "output") + with mock.patch.object(bundle, "committed_file", return_value=b"dirty"), mock.patch.object(bundle.subprocess, "check_output", return_value=self.tooling_sha + "\n"): + with self.assertRaisesRegex(ValueError, "executing tooling"): + bundle.generate(self.root, self.tooling_sha, self.profile_path, self.archive, self.root / "new") + self.archive.write_bytes(b"tampered") + with self.assertRaisesRegex(ValueError, "archive checksum"): + verify.verify_archive(self.archive, self.profile) + + def test_profile_schema_and_no_silent_certification(self): + for field, value in (("native_certified", True), ("schema", 2), ("package_release", True), ("profile_id", "a';false"), ("architecture", "aarch64")): + candidate = copy.deepcopy(self.profile) + candidate[field] = value + with self.subTest(field=field), self.assertRaises(ValueError): + verify.validate_profile(candidate) + candidate = copy.deepcopy(self.profile) + candidate["hyprland"]["header_version"] = "0.57.0" + with self.assertRaisesRegex(ValueError, "unchanged source"): + verify.validate_profile(candidate) + with self.assertRaisesRegex(ValueError, "duplicate JSON"): + verify.read_json('{"schema":1,"schema":1}') + + def test_source_inventory_tamper_and_embedded_verifier_preserved(self): + source = self.source() + self.assertEqual(verify.verify_source(source, self.profile), self.manifest) + for name in ("verify.py", "src/plugin.cpp", "SOURCE-PROVENANCE.json"): + path = source / name + original = path.read_bytes() + path.write_bytes(b"tampered") + with self.subTest(name=name), self.assertRaises(ValueError): + verify.verify_source(source, self.profile) + path.write_bytes(original) + (source / "extra").write_text("extra") + with self.assertRaisesRegex(ValueError, "inventory"): + verify.verify_source(source, self.profile) + (source / "extra").unlink() + (source / "linked").symlink_to(source / "verify.py") + with self.assertRaisesRegex(ValueError, "nonregular"): + verify.verify_source(source, self.profile) + + def test_archive_refuses_links_traversal_duplicates_and_missing_files(self): + for variant in ("symlink", "traversal", "duplicate", "missing"): + raw = io.BytesIO() + with tarfile.open(fileobj=raw, mode="w:gz") as contents: + for name, data in self.files.items(): + if variant == "missing" and name == "verify.py": + continue + info = tarfile.TarInfo(verify.STEM + "/" + name) + info.size = len(data) + contents.addfile(info, io.BytesIO(data)) + if variant != "missing": + info = tarfile.TarInfo(verify.STEM + "/" + {"symlink": "link", "traversal": "../escape", "duplicate": "verify.py"}[variant]) + if variant == "symlink": + info.type, info.linkname = tarfile.SYMTYPE, "verify.py" + contents.addfile(info) + candidate = self.root / (variant + ".tar.gz") + candidate.write_bytes(raw.getvalue()) + profile = copy.deepcopy(self.profile) + profile["source"]["archive_sha256"] = verify.digest(candidate) + with self.subTest(variant=variant), self.assertRaises(ValueError): + verify.verify_archive(candidate, profile) + + def test_kit_tampering_and_reviewed_digest_required(self): + _, kit, _ = self.generate() + expected = verify.digest(kit / "KIT-PROVENANCE.json") + for name in ("PROFILE.json", "KIT-PROVENANCE.json", "profile_verify.py"): + path = kit / name + data = path.read_bytes() + path.write_bytes(b"tamper") + with self.subTest(name=name), self.assertRaises(ValueError): + verify.verify_kit(kit, expected) + path.write_bytes(data) + with self.assertRaises(ValueError): + verify.verify_kit(kit, "e" * 64) + + def test_recipe_tamper_refused_before_execution_and_tests_mandatory(self): + _, kit, _ = self.generate() + for name in (self.archive.name, "PROFILE.json", "KIT-PROVENANCE.json", "profile_verify.py"): + path = kit / name + data = path.read_bytes() + path.write_bytes(b"raise SystemExit('must not execute')") + result = subprocess.run(["bash", "-c", 'source "$1"; startdir="$2"; srcdir="$2"; SRCDEST="$2"; prepare', "test", str(kit / "PKGBUILD"), str(kit)], capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertNotIn("SyntaxError", result.stderr) + path.write_bytes(data) + script = '''source "$1" +_verify() { return 0; } +ctest() { [[ -z ${LD_PRELOAD+x} && -z ${FAKEROOTKEY+x} && -z ${FAKED_MODE+x} ]] || return 88; return 17; } +export LD_PRELOAD=fixture FAKEROOTKEY=fixture FAKED_MODE=fixture +package +result=$? +[[ $LD_PRELOAD == fixture && $FAKEROOTKEY == fixture && $FAKED_MODE == fixture ]] || exit 89 +exit "$result" +''' + result = subprocess.run(["bash", "-c", script, "test", str(kit / "PKGBUILD")], capture_output=True, text=True) + self.assertEqual(result.returncode, 1) + + def test_profile_lifecycle_kit_preserves_source_identity(self): + _, kit, metadata = self.generate() + result = lifecycle.verify_profile_kit(kit, verify.SOURCE_REVISION, verify.DRIVER_VERSION, verify.digest(kit / "KIT-PROVENANCE.json")) + self.assertEqual(result[0], self.manifest) + self.assertEqual(result[2], self.profile) + self.assertEqual(result[3], metadata) + (kit / "build").mkdir() + with self.assertRaisesRegex(ValueError, "fresh complete"): + lifecycle.verify_profile_kit(kit, verify.SOURCE_REVISION, verify.DRIVER_VERSION, verify.digest(kit / "KIT-PROVENANCE.json")) + + def test_reviewed_recipe_reconstruction_refuses_changed_recipe_and_checksums(self): + _, kit, _ = self.generate() + recipe = kit / "PKGBUILD" + original = verify.digest(recipe) + recipe.write_bytes(recipe.read_bytes() + b"\n# unreviewed change\n") + sums = kit / "SHA256SUMS" + sums.write_text(sums.read_text().replace(original, verify.digest(recipe))) + with self.assertRaisesRegex(ValueError, "recipe differs"): + lifecycle.verify_profile_kit(kit, verify.SOURCE_REVISION, verify.DRIVER_VERSION, verify.digest(kit / "KIT-PROVENANCE.json")) + + def test_profile_package_payload_and_provenance(self): + _, _, metadata = self.generate() + module = b"synthetic module" + runtime_sha = self.profile["runtime"]["sha256"] + build = {"source": self.manifest, "profile": self.profile, "kit": metadata, "module_sha256": verify.sha256(module), + "module_runtime_sha256": runtime_sha, "compiler_runtime_sha256": runtime_sha, "compositor_runtime_sha256": runtime_sha} + data = {lifecycle.MODULE: module, lifecycle.LICENSE: self.files["LICENSE.md"], lifecycle.SOURCE: self.files["SOURCE-PROVENANCE.json"], + lifecycle.BUILD: verify.json_bytes(build), lifecycle.PROFILE: verify.json_bytes(self.profile), + lifecycle.KIT: verify.json_bytes(metadata), lifecycle.VERIFIER: (HERE / "profile_verify.py").read_bytes()} + names = list(data) + [".PKGINFO", ".BUILDINFO", ".MTREE"] + info = "pkgname = cua-hyprland-plugin\npkgver = 0.24.0-2\narch = x86_64\ndepend = hyprland=0.56.2-2\ndepend = gcc-libs=16.2.1-1\ndepend = python>=3.11\ndepend = binutils\n" + with mock.patch.object(lifecycle, "run", side_effect=lambda command: subprocess.CompletedProcess(command, 0, "\n".join(names) if "-tf" in command else info, "")), mock.patch.object(lifecycle.subprocess, "check_output", side_effect=lambda command: data[command[-1]]): + self.assertEqual(lifecycle.package_payload(self.root / "package", self.manifest, self.profile, metadata), {name: verify.sha256(value) for name, value in data.items()}) + for name in (lifecycle.PROFILE, lifecycle.KIT, lifecycle.VERIFIER): + original = data[name] + data[name] = b"{}" + with self.subTest(name=name), self.assertRaises(ValueError): + lifecycle.package_payload(self.root / "package", self.manifest, self.profile, metadata) + data[name] = original + names.append(".INSTALL") + with self.assertRaisesRegex(ValueError, "payload or hooks"): + lifecycle.package_payload(self.root / "package", self.manifest, self.profile, metadata) + + def test_profile_abi_dependencies_are_reviewed_safe_names(self): + self.profile["runtime"]["packages"]["hyprutils"] = "0.14.2-1" + verify.validate_profile(self.profile) + self.profile["runtime"]["packages"]["bad';command"] = "1-1" + with self.assertRaisesRegex(ValueError, "package name"): + verify.validate_profile(self.profile) + + def test_exact_package_owned_header_tree_and_api_bytes(self): + headers = self.root / "headers" + api = headers / "src/plugins/PluginAPI.hpp" + api.parent.mkdir(parents=True) + api.write_text("synthetic API") + with mock.patch.object(verify, "run", return_value=str(api) + "\n" + str(api.parent) + "/"): + first = verify.header_inventory_sha256(headers) + self.assertEqual(first, verify.sha256(verify.json_bytes({"src/plugins/PluginAPI.hpp": verify.digest(api)}))) + api.write_text("changed API") + self.assertNotEqual(first, verify.header_inventory_sha256(headers)) + extra = headers / "extra.hpp" + extra.write_text("unowned header") + with self.assertRaisesRegex(ValueError, "package header inventory"): + verify.header_inventory_sha256(headers) + extra.unlink() + api.unlink() + api.symlink_to(self.archive) + with self.assertRaisesRegex(ValueError, "nonregular"): + verify.header_inventory_sha256(headers) + + def test_profile_lifecycle_uses_exact_dependency_and_package_revisions(self): + self.profile["runtime"]["packages"]["hyprutils"] = "0.14.2-1" + package = self.root / "candidate.pkg.tar.zst" + package.write_text("package fixture") + data = {lifecycle.MODULE: b"module fixture"} + payload = {name: verify.sha256(value) for name, value in data.items()} + installed = set() + calls = [] + + def fake_pacman(command, **kwargs): + calls.append(command) + root = Path(command[command.index("--root") + 1]) + operation = command[command.index("--noconfirm") + 1:] + self.assertNotIn("--nodeps", command) + self.assertEqual("--noscriptlet" in command, operation[0] in ("-U", "-R")) + code, output = 0, "" + if operation == ["-U", str(package)]: + if root.name == "mismatched": + code, output = 1, "unable to satisfy dependency 'hyprland=0.56.2-2'" + else: + for name, value in data.items(): + path = root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(value) + installed.add(root) + elif operation[0] == "-R": + for name in data: + (root / name).unlink() + installed.remove(root) + elif operation[0] == "-Q": + code = 0 if root in installed else 1 + output = "cua-hyprland-plugin 0.24.0-2" if code == 0 else "" + return subprocess.CompletedProcess(command, code, output, "") + + with mock.patch.object(lifecycle, "run", side_effect=fake_pacman): + lifecycle.qualify(self.root, package, payload, self.manifest, self.profile) + lifecycle.assert_state(self.root / "matching", payload, True) + lifecycle.assert_state(self.root / "mismatched", payload, False) + self.assertEqual(len(calls), 10) + for name, version in (("hyprutils", "0.14.2-1"), ("gcc-libs", "16.2.1-1"), ("python", "3.11.0-1")): + with tarfile.open(self.root / f"{name}-fixture.pkg.tar.gz") as archive: + self.assertIn(f"pkgver = {version}\n".encode(), archive.extractfile(".PKGINFO").read()) + + +class NativeProfileTest(unittest.TestCase): + generate = ProfileTest.generate + source = ProfileTest.source + + def setUp(self): + ProfileTest.setUp(self) + self.cxx = self.root / "g++" + self.cxx.write_text("compiler") + self.runtime = self.root / self.profile["runtime"]["basename"] + self.runtime.write_text("runtime") + self.profile["runtime"]["sha256"] = verify.digest(self.runtime) + self.profile["compiler"]["sha256"] = verify.digest(self.cxx) + self.calls = [] + self.overrides = {} + self.probe_comment = None + + def fake_run(self, *args, input=None): + self.calls.append(args) + if args in self.overrides: + return self.overrides[args] + if args[:2] == ("pacman", "-Q"): + versions = {"hyprland": self.profile["hyprland"]["package_version"], **self.profile["runtime"]["packages"]} + return args[2] + " " + versions[args[2]] + if args[:2] == ("pacman", "-Qoq"): + return "gcc-libs" + if args[0] == "pkg-config": + return "0.56.2" + if args[:3] == ("readelf", "-p", ".comment"): + if args[-1].endswith("probe.o") and self.probe_comment: + return self.probe_comment + return " [ 0] " + self.profile["compiler"]["comment"] + if args[:2] == ("readelf", "-d"): + return "Shared library: [libstdc++.so.6]" + if args[0] == "ldd": + return f" libstdc++.so.6 => {self.runtime} (0x0)" + if "-dM" in args: + return '#define __VERSION__ "' + self.profile["compiler"]["version"] + '"' + if "--print-file-name=libstdc++.so.6" in args: + return str(self.runtime) + return "" + + def native_context(self): + patches = [mock.patch.object(verify.platform, "system", return_value="Linux"), + mock.patch.object(verify.platform, "machine", return_value="x86_64"), + mock.patch.object(verify, "run", side_effect=self.fake_run), + mock.patch.object(verify, "header_inventory_sha256", return_value="d" * 64)] + real_digest = verify.digest + patches.append(mock.patch.object(verify, "digest", side_effect=lambda path: self.profile["hyprland"]["sha256"] if str(path) == "/usr/bin/Hyprland" else real_digest(path))) + for patch in patches: + patch.start() + self.addCleanup(patch.stop) + + def test_native_profile_exact_checks_and_refusals(self): + self.native_context() + native = verify.verify_native(self.cxx, self.profile) + self.assertEqual(native["compiler_runtime_sha256"], self.profile["runtime"]["sha256"]) + for command, output in ((('pacman', '-Q', 'hyprland'), 'hyprland 0.56.2-1'), + (('pacman', '-Q', 'gcc-libs'), 'gcc-libs 0-1'), + (('pkg-config', '--modversion', 'hyprland'), '0.56.3'), + ((str(self.cxx), '-dM', '-E', '-x', 'c++', '-'), '#define __VERSION__ "wrong"'), + (('readelf', '-p', '.comment', '/usr/bin/Hyprland'), 'wrong comment'), + (('readelf', '-d', '/usr/bin/Hyprland'), 'static runtime')): + self.overrides[command] = output + with self.subTest(command=command), self.assertRaises(ValueError): + verify.verify_native(self.cxx, self.profile) + self.overrides.clear() + with mock.patch.object(verify, "header_inventory_sha256", return_value="e" * 64), self.assertRaisesRegex(ValueError, "header inventory"): + verify.verify_native(self.cxx, self.profile) + self.probe_comment = "emitted by a different compiler" + with self.assertRaisesRegex(ValueError, "ELF compiler comment"): + verify.verify_native(self.cxx, self.profile) + self.probe_comment = None + self.overrides[("pacman", "-Qoq", str(self.runtime.resolve()))] = "unreviewed-runtime-owner" + with self.assertRaisesRegex(ValueError, "owner is not pinned"): + verify.verify_native(self.cxx, self.profile) + self.overrides.clear() + self.cxx.write_text("different compiler executable") + with self.assertRaisesRegex(ValueError, "compiler checksum"): + verify.verify_native(self.cxx, self.profile) + self.cxx.write_text("compiler") + self.runtime.write_text("different bytes") + with self.assertRaisesRegex(ValueError, "runtime mismatch"): + verify.verify_native(self.cxx, self.profile) + + def test_build_configuration_and_shared_runtime_checks(self): + self.native_context() + source = self.source() + build = self.root / "build" + build.mkdir() + (build / "cua-hyprland-plugin.so").write_text("module") + expected = dict(verify.OPTIONS, BUILD_TESTING="ON", CUA_HYPRLAND_BUILD_PLUGIN="ON", CMAKE_BUILD_TYPE="Release", + CMAKE_GENERATOR="Ninja", + CUA_HYPRLAND_EXPECTED_VERSION="0.56.2", CUA_HYPRLAND_TEST_OPERATOR_KEY="", + CMAKE_CXX_COMPILER=str(self.cxx), CMAKE_HOME_DIRECTORY=str(source.resolve())) + cache = build / "CMakeCache.txt" + cache.write_text("".join(f"{key}:STRING={value}\n" for key, value in expected.items())) + verify.verify_build(build, source, self.cxx, self.profile) + for key in expected: + changed = dict(expected, **{key: "WRONG"}) + cache.write_text("".join(f"{name}:STRING={value}\n" for name, value in changed.items())) + with self.subTest(key=key), self.assertRaisesRegex(ValueError, "build configuration mismatch"): + verify.verify_build(build, source, self.cxx, self.profile) + + def test_consumer_does_not_invoke_compiler_or_headers_and_refuses_drift(self): + self.native_context() + self.profile_path.write_bytes(verify.json_bytes(self.profile)) + _, kit, metadata = self.generate() + module = self.root / "module.so" + module.write_text("module") + native = verify.verify_native(self.cxx, self.profile) + build = dict(native, source=self.manifest, profile=self.profile, kit=metadata, + module_sha256=verify.digest(module), module_runtime_sha256=self.profile["runtime"]["sha256"]) + (kit / "BUILD-PROVENANCE.json").write_bytes(verify.json_bytes(build)) + self.cxx.unlink() + self.calls.clear() + verify.verify_consumer(module, kit, self.profile, metadata) + self.assertTrue(all(call[0] in ("pacman", "readelf", "ldd") for call in self.calls)) + module.write_text("drift") + with self.assertRaisesRegex(ValueError, "module checksum"): + verify.verify_consumer(module, kit, self.profile, metadata) + + +if __name__ == "__main__": + unittest.main() diff --git a/libs/cua-driver/hyprland-plugin/tests/production-inkscape-profile.md b/libs/cua-driver/hyprland-plugin/tests/production-inkscape-profile.md new file mode 100644 index 0000000000..ed2ae9bb5f --- /dev/null +++ b/libs/cua-driver/hyprland-plugin/tests/production-inkscape-profile.md @@ -0,0 +1,100 @@ +# Bounded Inkscape-only qualification profile + +This is a harness interface, not a native certification result. It does not +change production app admission, qualify current LibreOffice, or replace the +complete native Hyprland `scripts/ci/linux/run-rust-e2e.sh` all suite and its +required evidence. The default `calc-inkscape` profile remains unchanged. + +Use `"app_profile": "inkscape-only"` in a reviewed +`production_realapp_proof.py` plan. Its exact package inventory is +`"package_versions": {"inkscape": "1.4.4-6"}`. Each agent must name +`"app": "inkscape"`, an absolute synthetic `document` SVG path, and its exact +`target: {pid, window_id}`. Keep the existing session, permission `profile`, +window `bounds`, foreground fixture, phases, and action-grounding fields. +The app qualification profile is separate from each Driver permission profile; +unrestricted sessions still require `acknowledge_unrestricted: true`. + +Prepare two distinct native Inkscape processes for `purpose: "apps"`, and +three for `purpose: "capacity"`. This runner consumes prelaunched targets. +Verify the installed application's supported independent-process launch method; +do not assume a `--new-instance` flag. The single-app smoke already launches a +positional document and independently verifies that its process is new. +The profile checks the canonical executable `/usr/bin/inkscape`, exact ALPM +ownership/version, GTK3 mappings, one native Wayland client per PID, exact +Hyprland address, document title, and the absolute document argument in the +process command line. Shared PIDs, duplicate windows/documents, XWayland, +dialogs, stale targets, and unsupported grounding fail the proof. + +Each app lane needs its own saved SVG oracle, bound to that agent's `document`: + +```json +{ + "agent": 0, + "path": "/synthetic/lane-0.svg", + "format": "svg", + "xpath": ".//svg:rect[@id='smoke-rectangle']", + "namespaces": {"svg": "http://www.w3.org/2000/svg"}, + "rect_translation": [[2, 2], [0, 0]] +} +``` + +Set translation bounds to the reviewed action's expected effect. The harness +requires a changed native SVG, an identified rectangle with unchanged size, +and nonzero movement within finite bounds. Intermediate pointer episodes keep +the existing no-save contract; the save episode checks both lanes' outputs. +The existing exact keyboard `smoke_stage` and image-derived `pointer_stage` +paths remain available for each Inkscape lane. + +Keep `require_overlap: true` on the drag episode and retain the traced overlap +threshold. Capacity remains a separate serial plan: agents 0 and 1 dispatch, +then agent 2 receives exactly `{"kind":"refused","reason":"lane_busy"}`. +Trace evidence must identify compositor lanes 1 and 2 and no third dispatch. +The new profile also checks both earlier reservations and their epochs across +the refusal while the original Driver runtimes remain alive. Existing primary +trace, negative control, fresh snapshots, no-retry, and input-cleanup checks +remain required. Capacity does not itself prove saved app effects or overlap. + +## Source and artifact identities + +Every production proof helper accepts the existing `--source` and +`--source-sha` for the original product checkout, plus the optional pair +`--harness-source` and `--harness-sha` for the checkout containing this runner. +Both checkouts must be clean Git roots at their exact full SHAs. Without the +pair, the runner must belong to the product checkout as before. Detached state +is recorded with an empty branch. Neither checkout identity alone proves how +a binary was built. Driver/module hashes and the active module's exact kernel +mapping checks remain recorded and enforced. + +The new app profile requires an explicit artifact role: + +- `--artifact-role diagnostic --trace-socket /path/to/trace.sock` identifies + the instrumented proof. Do not pass production kit manifests for that module. +- `--artifact-role production --kit-manifest /path/to/KIT-PROVENANCE.json + --profile-manifest /path/to/PROFILE.json + --build-provenance /path/to/BUILD-PROVENANCE.json` identifies the trace-disabled + package. Supply all three files together and omit `--trace-socket`. + +Production checks bind the raw profile digest, kit source revision, embedded +build source, exact production CMake flags, and built module digest to the +actual mapped module. All three manifest digests are retained. Kit tooling +revision is recorded separately and can differ from the later harness revision. +This binding complements the packaging verifier and package integrity checks; +it is not a package signature, compiler/runtime compatibility check, or native +certification. Diagnostic attribution cannot certify trace-disabled bytes. +Production runs retain the existing `production-package-smoke` scope and leave +continuous trace isolation unproven. Separate fresh-session package lifecycle +and independent primary-input observations remain required for shipping. + +`production_app_smoke.py --app-profile inkscape-only` runs the existing bounded +single-app keyboard smoke with only the Inkscape package gate and SVG fixture. +It uses the production artifact role and the same source/manifest flags. Its limits remain explicit: no +concurrency, complete isolation, or full desktop certification claim. +Fault/cancellation helper plans may also select `app_profile`; retain their +existing required scenario fields and supply `document` on native app agents. + +Portable regression command (Python 3.10 or newer): + +```sh +cd libs/cua-driver/hyprland-plugin/tests +python3 -m unittest discover -b -p 'production*_test.py' +``` diff --git a/libs/cua-driver/hyprland-plugin/tests/production_active_lock_proof.py b/libs/cua-driver/hyprland-plugin/tests/production_active_lock_proof.py index 8ed6cab420..9e6fe0fa72 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production_active_lock_proof.py +++ b/libs/cua-driver/hyprland-plugin/tests/production_active_lock_proof.py @@ -17,6 +17,7 @@ Portable tests exercise orchestration and failure oracles, not native behavior. """ import argparse +from production_app_smoke import add_provenance_arguments from concurrent.futures import ThreadPoolExecutor import hashlib import json @@ -429,5 +430,5 @@ def release_primary(): for name in ('driver', 'plugin', 'source', 'primary-grab', 'plan', 'evidence', 'foreground-journal', 'trace-socket', 'lock-fixture'): parser.add_argument('--' + name, required=True, type=Path) - parser.add_argument('--source-sha', required=True) + add_provenance_arguments(parser) raise SystemExit(run(parser.parse_args())) diff --git a/libs/cua-driver/hyprland-plugin/tests/production_active_primary_proof.py b/libs/cua-driver/hyprland-plugin/tests/production_active_primary_proof.py index 316fa780d8..089f58cfef 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production_active_primary_proof.py +++ b/libs/cua-driver/hyprland-plugin/tests/production_active_primary_proof.py @@ -17,6 +17,7 @@ Portable tests establish preparation only, never native certification. """ import argparse +from production_app_smoke import add_provenance_arguments from concurrent.futures import ThreadPoolExecutor import hashlib import json @@ -485,5 +486,5 @@ def preserve_trace(): for name in ('driver', 'plugin', 'source', 'primary-grab', 'hover-fixture', 'plan', 'evidence', 'foreground-journal', 'trace-socket'): parser.add_argument('--' + name, required=True, type=Path) - parser.add_argument('--source-sha', required=True) + add_provenance_arguments(parser) raise SystemExit(run(parser.parse_args())) diff --git a/libs/cua-driver/hyprland-plugin/tests/production_app_profile_test.py b/libs/cua-driver/hyprland-plugin/tests/production_app_profile_test.py new file mode 100644 index 0000000000..0564fee6f9 --- /dev/null +++ b/libs/cua-driver/hyprland-plugin/tests/production_app_profile_test.py @@ -0,0 +1,267 @@ +"""Explicit app-profile contract tests; no native input or qualification claims.""" +import copy +import json +from pathlib import Path +import subprocess +import sys +import tempfile +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +from production_app_smoke import (PACKAGES, artifact_identity, create_documents, + digest, package_owner, profile_packages, source_identities) +from production_realapp_proof import (capacity_reservations, inkscape_client_identity, + validate_plan, verify_output) +from production_realapp_proof_test import capacity_plan, plan + + +def inkscape_plan(capacity=False): + candidate = capacity_plan() if capacity else plan() + candidate['app_profile'] = 'inkscape-only' + candidate['package_versions'] = {'inkscape': '1.4.4-6'} + for index, spec in enumerate(candidate['agents']): + spec.update(app='inkscape', document=f'/synthetic/lane-{index}.svg') + candidate['outputs'] = [] if capacity else [ + {'agent': i, 'path': spec['document'], 'format': 'svg', + 'xpath': './/svg:rect[@id="smoke-rectangle"]', + 'namespaces': {'svg': 'http://www.w3.org/2000/svg'}, + 'rect_translation': [[2, 2], [0, 0]]} + for i, spec in enumerate(candidate['agents'])] + return candidate + + +class AppProfileTests(unittest.TestCase): + def test_profile_is_explicit_and_keeps_exact_package_gate(self): + self.assertEqual(profile_packages('calc-inkscape'), PACKAGES) + self.assertEqual(profile_packages('inkscape-only'), {'inkscape': '1.4.4-6'}) + for value in ('all', 'inkscape', '', None): + with self.subTest(value=value), self.assertRaises(AssertionError): + profile_packages(value) + + def test_single_app_smoke_does_not_create_calc_document(self): + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + documents = create_documents(directory, 'inkscape-only') + self.assertEqual(list(documents), ['inkscape']) + self.assertEqual(list(directory.iterdir()), [documents['inkscape']]) + with self.assertRaises(FileExistsError): + create_documents(directory, 'inkscape-only') + + def test_inkscape_executable_owner_and_version_are_both_exact(self): + with tempfile.TemporaryDirectory() as temporary: + executable = Path(temporary).resolve() / 'inkscape' + executable.write_bytes(b'synthetic executable') + executable.chmod(0o700) + for replies in (['other-package'], ['inkscape', 'inkscape 1.4.4-7']): + with patch('production_app_smoke.read', side_effect=replies), self.assertRaises(AssertionError): + package_owner(executable, 'inkscape') + with patch('production_app_smoke.read', side_effect=['inkscape', 'inkscape 1.4.4-6']): + self.assertEqual(package_owner(executable, 'inkscape'), digest(executable)) + + def test_apps_and_capacity_need_explicit_profile_and_independent_clients(self): + for capacity in (False, True): + candidate = inkscape_plan(capacity) + validate_plan(candidate) + for mutate in ( + lambda p: p.pop('app_profile'), + lambda p: p.update(app_profile='arbitrary'), + lambda p: p['agents'][0].update(app='calc'), + lambda p: p['agents'][1]['target'].update(pid=p['agents'][0]['target']['pid']), + lambda p: p['agents'][1]['target'].update(window_id=p['agents'][0]['target']['window_id']), + lambda p: p['agents'][0]['target'].update(window_id=None), + lambda p: p['agents'][1].update(document=p['agents'][0]['document']), + lambda p: p['agents'][0].update(document='relative.svg'), + ): + bad = copy.deepcopy(candidate) + mutate(bad) + with self.subTest(capacity=capacity, plan=bad), self.assertRaises(AssertionError): + validate_plan(bad) + # The historical Calc/Inkscape profiles keep working unchanged. + validate_plan(plan()) + validate_plan(capacity_plan()) + + def test_two_app_lanes_need_distinct_owned_native_svg_oracles(self): + for mutate in ( + lambda p: p.update(outputs=p['outputs'][:1]), + lambda p: p['outputs'][1].update(path=p['outputs'][0]['path']), + lambda p: p['outputs'][0].update(path='/synthetic/other.svg'), + lambda p: p['outputs'][0].update(format='ods'), + lambda p: p['outputs'][0].update(zip_member='content.xml'), + lambda p: p['outputs'][0].pop('rect_translation'), + ): + candidate = inkscape_plan() + mutate(candidate) + with self.subTest(plan=candidate), self.assertRaises(AssertionError): + validate_plan(candidate) + + def test_capacity_keeps_two_admissions_exact_third_refusal_and_serial_order(self): + for mutate in ( + lambda p: p.update(agents=p['agents'][:2]), + lambda p: p['phases'][2].update(expect={'kind': 'dispatched'}), + lambda p: p['phases'][2]['expect'].update(reason='target_unavailable'), + lambda p: p['phases'][2].update(agent=0), + lambda p: p.update(require_overlap=True), + lambda p: p.update(moving_primary=True), + ): + candidate = inkscape_plan(True) + mutate(candidate) + with self.subTest(plan=candidate), self.assertRaises(AssertionError): + validate_plan(candidate) + + def test_capacity_owners_must_retain_both_reservations_through_third_refusal(self): + status = {'state': 'input_v3_candidate', 'input': {'protocol': 3, 'test_only': False, + 'transport_ready': True, 'lanes': [ + {'lane': lane, 'reserved': True, 'lease_active': False, 'drag_active': False, + 'held_keys': 0, 'held_button': 0, 'epoch': 50 + lane, 'desktop_generation': 4} + for lane in (0, 1)]}} + previous = capacity_reservations(status, [1], {}) + both = capacity_reservations(status, [1, 2], previous) + self.assertEqual(capacity_reservations(status, [1, 2], both), both) + for lane in (0, 1): + for change in ({'reserved': False}, {'epoch': 100}, {'desktop_generation': 5}, + {'held_keys': 1}, {'held_button': 272}, {'lease_active': True}): + bad = copy.deepcopy(status) + bad['input']['lanes'][lane].update(change) + with self.subTest(lane=lane, change=change), self.assertRaises(AssertionError): + capacity_reservations(bad, [1, 2], both) + + def test_saved_svg_must_change_exact_rectangle_without_resizing(self): + with tempfile.TemporaryDirectory() as temporary: + original = create_documents(Path(temporary), 'inkscape-only')['inkscape'].read_bytes() + oracle = inkscape_plan()['outputs'][0] + changed = original.replace(b'x="40"', b'x="42"') + self.assertTrue(verify_output(original, changed, oracle)['verified']) + for bad in (original, changed.replace(b'', b''), + changed.replace(b'width="80"', b'width="81"'), + changed.replace(b'x="42"', b'x="44"')): + with self.assertRaises(AssertionError): + verify_output(original, bad, oracle) + + def test_prelaunched_process_must_own_exact_native_document(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary).resolve() + document = create_documents(root, 'inkscape-only')['inkscape'] + process = root / '20' + process.mkdir() + (process / 'cmdline').write_bytes(b'/usr/bin/inkscape\0' + str(document).encode() + b'\0') + spec = {'target': {'pid': 20, 'window_id': 200}, 'document': str(document)} + window = {'pid': 20, 'address': '0xc8', 'xwayland': False, 'title': document.name} + with patch('production_realapp_proof.app_process_identity', return_value={'pid': 20}) as identity: + result = inkscape_client_identity(spec, [window], root) + identity.assert_called_once_with('inkscape', 20, root) + self.assertEqual(result['document']['path'], str(document)) + for change in ({'pid': 21}, {'address': '0xc9'}, {'xwayland': True}, + {'title': 'unrelated.svg'}): + with self.subTest(change=change), self.assertRaises(AssertionError): + inkscape_client_identity(spec, [{**window, **change}], root) + with self.assertRaises(AssertionError): + inkscape_client_identity(spec, [window, window], root) + (process / 'cmdline').write_bytes(b'/usr/bin/inkscape\0/unrelated.svg\0') + with self.assertRaisesRegex(AssertionError, 'not bound'): + inkscape_client_identity(spec, [window], root) + + +class ProfileProvenanceTests(unittest.TestCase): + def test_helper_clis_expose_separate_source_and_artifact_options(self): + names = ('app_smoke', 'realapp_proof', 'active_lock_proof', 'active_primary_proof', + 'cancel_proof', 'desktop_fault_proof', 'geometry_fault_proof', 'idle_reconnect_proof', + 'lock_refusal_proof', 'primary_conflict_proof', 'session_fault_proof', + 'target_lifetime_proof', 'policy_proof') + for name in names: + with self.subTest(helper=name): + output = subprocess.check_output( + [sys.executable, str(Path(__file__).with_name(f'production_{name}.py')), '--help'], + text=True, timeout=10) + for option in ('--source-sha', '--harness-source', '--harness-sha', '--artifact-role', + '--kit-manifest', '--profile-manifest', '--build-provenance'): + self.assertIn(option, output) + + def test_clean_exact_product_and_harness_checkouts_are_independent(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary).resolve() + product, harness = root / 'product', root / 'harness' + product.mkdir() + harness.mkdir() + revisions = {str(product): 'a' * 40, str(harness): 'b' * 40} + def command(args): + checkout = args[2] + return {'--show-toplevel': checkout, 'HEAD': revisions[checkout], + '--porcelain': '', '--show-current': ''}[args[-1]] + args = SimpleNamespace(source=product, source_sha='a' * 40, + harness_source=harness, harness_sha='b' * 40) + with patch('production_app_smoke.read', side_effect=command), \ + patch('production_app_smoke.__file__', str(harness / 'tests/runner.py')): + source, runner = source_identities(args) + self.assertEqual(source['source_sha'], 'a' * 40) + self.assertEqual(runner['source_sha'], 'b' * 40) + self.assertEqual(runner['branch'], '') # Detached stays detached. + for change in ({'harness_sha': 'c' * 40}, {'harness_sha': None}, + {'harness_source': product, 'harness_sha': 'a' * 40}, + {'source_sha': 'c' * 40}): + with self.subTest(change=change), self.assertRaises(AssertionError): + source_identities(SimpleNamespace(**{**vars(args), **change})) + for checkout in (product, harness): + def dirty(command_args): + return ' M changed.py' if command_args[2] == str(checkout) and command_args[-1] == '--porcelain' else command(command_args) + with patch('production_app_smoke.read', side_effect=dirty), self.assertRaisesRegex(AssertionError, 'clean checkout'): + source_identities(args) + + def test_production_manifest_trio_binds_source_profile_and_actual_module(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + plugin = root / 'plugin.so' + plugin.write_bytes(b'synthetic module bytes') + source = {'revision': 'a' * 40, 'driver_version': '0.24.0'} + profile = {'profile_id': 'synthetic-profile', 'source': source} + profile_path = root / 'PROFILE.json' + profile_path.write_text(json.dumps(profile)) + kit = {'schema': 1, 'tooling_revision': 'b' * 40, 'source': source, + 'profile_sha256': digest(profile_path)['sha256'], + 'cmake_options': {'CUA_HYPRLAND_INPUT': 'ON', 'CUA_HYPRLAND_TEST_INPUT': 'OFF', + 'CUA_HYPRLAND_INPUT_TRACE': 'OFF'}} + build = {'source': {'source_revision': source['revision'], 'driver_version': '0.24.0'}, + 'profile': profile, 'kit': kit, 'module_sha256': digest(plugin)['sha256']} + kit_path, build_path = root / 'KIT-PROVENANCE.json', root / 'BUILD-PROVENANCE.json' + kit_path.write_text(json.dumps(kit)) + build_path.write_text(json.dumps(build)) + args = SimpleNamespace(artifact_role='production', kit_manifest=kit_path, + profile_manifest=profile_path, build_provenance=build_path) + product = {'source_sha': source['revision']} + self.assertEqual(artifact_identity(args, product, plugin, 'inkscape-only')['role'], 'production') + for change in ({'artifact_role': 'diagnostic', 'trace_socket': root / 'trace.sock'}, + {'build_provenance': None}, {'trace_socket': root / 'trace.sock'}, + {'artifact_role': None}): + with self.subTest(change=change), self.assertRaises(AssertionError): + artifact_identity(SimpleNamespace(**{**vars(args), **change}), product, plugin, 'inkscape-only') + for change in ({'module_sha256': '0' * 64}, {'profile': {}}, {'kit': {}}, + {'source': {'source_revision': 'c' * 40, 'driver_version': '0.24.0'}}): + build_path.write_text(json.dumps({**build, **change})) + with self.subTest(change=change), self.assertRaises(AssertionError): + artifact_identity(args, product, plugin, 'inkscape-only') + build_path.write_text(json.dumps(build)) + bad_kit = copy.deepcopy(kit) + bad_kit['cmake_options']['CUA_HYPRLAND_INPUT_TRACE'] = 'ON' + kit_path.write_text(json.dumps(bad_kit)) + build_path.write_text(json.dumps({**build, 'kit': bad_kit})) + with self.assertRaisesRegex(AssertionError, 'production kit configuration'): + artifact_identity(args, product, plugin, 'inkscape-only') + kit_path.write_text(json.dumps(kit)) + build_path.write_text(json.dumps(build)) + profile_path.write_text(json.dumps(profile) + '\n') + with self.assertRaisesRegex(AssertionError, 'profile digest mismatch'): + artifact_identity(args, product, plugin, 'inkscape-only') + + def test_diagnostic_identity_needs_trace_and_cannot_claim_production_kit(self): + diagnostic = SimpleNamespace(artifact_role='diagnostic', trace_socket=Path('/trace.sock')) + self.assertEqual(artifact_identity(diagnostic, {}, Path('/module'), 'inkscape-only'), {'role': 'diagnostic'}) + for args in (SimpleNamespace(), SimpleNamespace(artifact_role='diagnostic'), + SimpleNamespace(artifact_role='production')): + with self.assertRaises(AssertionError): + artifact_identity(args, {}, Path('/module'), 'inkscape-only') + self.assertEqual(artifact_identity(SimpleNamespace(), {}, Path('/module'), 'calc-inkscape'), + {'role': 'unspecified'}) + + +if __name__ == '__main__': + unittest.main() diff --git a/libs/cua-driver/hyprland-plugin/tests/production_app_smoke.py b/libs/cua-driver/hyprland-plugin/tests/production_app_smoke.py index 924635671f..231a0637f9 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production_app_smoke.py +++ b/libs/cua-driver/hyprland-plugin/tests/production_app_smoke.py @@ -25,6 +25,7 @@ PACKAGES = {'libreoffice-fresh': '26.2.5-3', 'inkscape': '1.4.4-6'} +APP_PROFILES = {'calc-inkscape': ('calc', 'inkscape'), 'inkscape-only': ('inkscape',)} EXECUTABLES = {'calc': Path('/usr/lib/libreoffice/program/soffice.bin'), 'inkscape': Path('/usr/bin/inkscape')} NS = {'office': 'urn:oasis:names:tc:opendocument:xmlns:office:1.0', @@ -61,6 +62,12 @@ def package_owner(path, package): return digest(path) +def profile_packages(name): + assert isinstance(name, str) and name in APP_PROFILES, 'unknown app qualification profile' + return {package: PACKAGES[package] for app in APP_PROFILES[name] + for package in ('libreoffice-fresh' if app == 'calc' else app,)} + + def kernel_file_identity(plugin): """Read the kernel mapping identity of the exact open candidate file. @@ -127,20 +134,82 @@ def require_enabled_plugin(option): and option.get('bool') is True and 'int' not in option, 'setup has not enabled the plugin' -def provenance(args): - source = args.source.resolve(strict=True) +def checkout_identity(source, expected_sha, label): + source = source.resolve(strict=True) assert Path(read(['git', '-C', str(source), 'rev-parse', '--show-toplevel'])).resolve() == source sha = read(['git', '-C', str(source), 'rev-parse', 'HEAD']) - assert re.fullmatch(r'[0-9a-f]{40}', args.source_sha) and sha == args.source_sha, 'source SHA mismatch' + assert re.fullmatch(r'[0-9a-f]{40}', expected_sha) and sha == expected_sha, f'{label} SHA mismatch' dirty = read(['git', '-C', str(source), 'status', '--porcelain']) - assert not dirty, 'exact-source smoke requires a clean checkout' - assert Path(__file__).resolve().is_relative_to(source), 'runner is outside declared source' - assert dict(line.split() for line in read(['pacman', '-Q', *PACKAGES]).splitlines()) == PACKAGES + assert not dirty, f'{label} requires a clean checkout' + return {'source': str(source), 'source_sha': sha, 'dirty': dirty, + 'branch': read(['git', '-C', str(source), 'branch', '--show-current'])} + + +def source_identities(args): + product = checkout_identity(args.source, args.source_sha, 'source') + harness_source = getattr(args, 'harness_source', None) + harness_sha = getattr(args, 'harness_sha', None) + assert (harness_source is None) == (harness_sha is None), 'harness source and SHA must be supplied together' + harness = (checkout_identity(harness_source, harness_sha, 'harness') + if harness_source is not None else dict(product)) + assert Path(__file__).resolve().is_relative_to(Path(harness['source'])), 'runner is outside declared harness source' + return product, harness + + +def add_provenance_arguments(parser): + parser.add_argument('--source-sha', required=True) + parser.add_argument('--harness-source', type=Path) + parser.add_argument('--harness-sha') + parser.add_argument('--artifact-role', choices=('diagnostic', 'production')) + parser.add_argument('--kit-manifest', type=Path) + parser.add_argument('--profile-manifest', type=Path) + parser.add_argument('--build-provenance', type=Path) + + +def artifact_identity(args, product, plugin, app_profile): + """Bind production metadata to the module; diagnostics never borrow its claim.""" + role = getattr(args, 'artifact_role', None) + paths = [getattr(args, name, None) for name in ('kit_manifest', 'profile_manifest', 'build_provenance')] + assert all(path is not None for path in paths) or all(path is None for path in paths), \ + 'kit, profile and build provenance must be supplied together' + if app_profile == 'inkscape-only': + assert role in ('diagnostic', 'production'), 'inkscape-only requires an explicit artifact role' + if role == 'diagnostic': + assert getattr(args, 'trace_socket', None), 'diagnostic proof requires a trace socket' + assert not any(paths), 'diagnostic module cannot claim the trace-disabled production kit' + if role == 'production' or any(paths): + assert role == 'production' and all(paths), 'production identity requires all package provenance manifests' + assert not getattr(args, 'trace_socket', None), 'production package proof cannot use diagnostic trace' + kit_path, profile_path, build_path = paths + kit, profile, build = [json.loads(path.read_text()) for path in paths] + assert kit['schema'] == 1 and re.fullmatch(r'[0-9a-f]{40}', kit['tooling_revision']) + assert kit['profile_sha256'] == digest(profile_path)['sha256'], 'kit profile digest mismatch' + assert kit['source']['revision'] == product['source_sha'], 'kit product source mismatch' + assert profile['source'] == kit['source'], 'profile product source mismatch' + assert build['source']['source_revision'] == product['source_sha'], 'build product source mismatch' + assert build['source']['driver_version'] == kit['source']['driver_version'], 'build Driver version mismatch' + assert build['kit'] == kit and build['profile'] == profile, 'build kit/profile provenance mismatch' + assert kit['cmake_options'] == {'CUA_HYPRLAND_INPUT': 'ON', 'CUA_HYPRLAND_TEST_INPUT': 'OFF', + 'CUA_HYPRLAND_INPUT_TRACE': 'OFF'}, 'not a production kit configuration' + assert build['module_sha256'] == digest(plugin)['sha256'], 'build module digest mismatch' + return {'role': role, 'kit': digest(kit_path), 'profile': digest(profile_path), + 'build': digest(build_path), 'profile_id': profile['profile_id'], + 'tooling_revision': kit['tooling_revision'], 'module_sha256': build['module_sha256']} + return {'role': role or 'unspecified'} + + +def provenance(args, app_profile=None): + app_profile = app_profile if app_profile is not None else getattr(args, 'app_profile', 'calc-inkscape') + packages = profile_packages(app_profile) + product, harness = source_identities(args) + source = Path(product['source']) + assert dict(line.split() for line in read(['pacman', '-Q', *packages]).splitlines()) == packages app_files = {app: package_owner(path, 'libreoffice-fresh' if app == 'calc' else app) - for app, path in EXECUTABLES.items()} - launcher = Path('/usr/bin/libreoffice').resolve(strict=True) - app_files['calc_launcher'] = package_owner(launcher, 'libreoffice-fresh') + for app, path in EXECUTABLES.items() if app in APP_PROFILES[app_profile]} + if 'calc' in APP_PROFILES[app_profile]: + launcher = Path('/usr/bin/libreoffice').resolve(strict=True) + app_files['calc_launcher'] = package_owner(launcher, 'libreoffice-fresh') signature = os.environ.get('HYPRLAND_INSTANCE_SIGNATURE') assert signature, 'missing active Hyprland instance identity' instances = json.loads(read(['hyprctl', '-j', 'instances'])) @@ -150,6 +219,7 @@ def provenance(args): assert Path(f'/proc/{pid}/exe').resolve(strict=True).name == 'Hyprland' plugin = args.plugin.resolve(strict=True) maps = mapped_plugin(Path(f'/proc/{pid}/maps').read_text(), plugin) + artifact = artifact_identity(args, product, plugin, app_profile) plugins = read(['hyprctl', 'plugin', 'list']) assert 'cua-hyprland-plugin' in plugins, 'plugin is mapped but not registered' enabled = json.loads(read(['hyprctl', '-j', 'getoption', 'plugin:cua:enabled'])) @@ -158,23 +228,20 @@ def provenance(args): files = {'driver': args.driver, 'plugin': plugin, **{name: Path(__file__).with_name(name) for name in ('production_app_smoke.py', 'production_mcp.py', 'driver_input_live.py')}} - return {'source': str(source), 'source_sha': sha, - 'branch': read(['git', '-C', str(source), 'branch', '--show-current']), - 'dirty': dirty, + return {**product, 'harness': harness, 'artifact': artifact, 'source_versions': { 'driver': re.search(r'(?m)^version = "([^"]+)"', (source / 'libs/cua-driver/rust/Cargo.toml').read_text())[1], 'plugin': re.search(r'project\(cua_hyprland_plugin VERSION ([\d.]+)', (source / 'libs/cua-driver/hyprland-plugin/CMakeLists.txt').read_text())[1]}, 'driver_version': read([str(args.driver), '--version']), - 'packages': PACKAGES, 'app_files': app_files, + 'app_profile': app_profile, 'packages': packages, 'app_files': app_files, 'files': {name: digest(path) for name, path in files.items()}, 'hyprland_pid': pid, 'hyprland_version': read(['hyprctl', 'version']), 'loaded_plugins': plugins, 'plugin_maps': maps, 'plugin_enabled': enabled} -def create_documents(directory): - """Minimal native formats, with one blank A1 and one uniquely named rectangle.""" +def create_calc_document(directory): ods = directory / 'cua-smoke-calc.ods' content = f''' ''') - return {'calc': ods, 'inkscape': svg} + return svg + + +def create_documents(directory, app_profile='calc-inkscape'): + """Minimal native formats, with one blank A1 and one uniquely named rectangle.""" + profile_packages(app_profile) + creators = {'calc': create_calc_document, 'inkscape': create_inkscape_document} + return {app: creators[app](directory) for app in APP_PROFILES[app_profile]} def verify_calc(before, after): @@ -532,7 +610,9 @@ def run(args): 'source': str(args.source), 'source_sha': args.source_sha, 'driver': digest(args.driver), 'plugin': digest(args.plugin)}) save_json(args.evidence, 'provenance.json', provenance(args)) - documents = create_documents(args.evidence) + app_profile = getattr(args, 'app_profile', 'calc-inkscape') + result['app_profile'] = app_profile + documents = create_documents(args.evidence, app_profile) # Inherited by the ordinary Driver launcher; preferences are run-local. os.environ.update(GDK_BACKEND='wayland', SAL_USE_VCLPLUGIN='gtk3', XDG_CONFIG_HOME=str(args.evidence / 'config')) @@ -552,7 +632,7 @@ def run(args): if mcp: mcp.close() statuses = [row['result'] for row in result['apps'].values()] - result['result'] = ('passed' if statuses == ['passed', 'passed'] else + result['result'] = ('passed' if statuses == ['passed'] * len(APP_PROFILES[app_profile]) else 'failed' if 'failed' in statuses else 'inspection_only') except Exception as error: result['error'] = str(error) @@ -571,5 +651,6 @@ def run(args): parser = argparse.ArgumentParser(description=__doc__) for name in ('source', 'driver', 'plugin', 'evidence'): parser.add_argument('--' + name, type=Path, required=True) - parser.add_argument('--source-sha', required=True) + add_provenance_arguments(parser) + parser.add_argument('--app-profile', choices=APP_PROFILES, default='calc-inkscape') raise SystemExit(run(parser.parse_args())) diff --git a/libs/cua-driver/hyprland-plugin/tests/production_cancel_proof.py b/libs/cua-driver/hyprland-plugin/tests/production_cancel_proof.py index b50f6d5a7b..0be52cc7aa 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production_cancel_proof.py +++ b/libs/cua-driver/hyprland-plugin/tests/production_cancel_proof.py @@ -5,6 +5,7 @@ telemetry proves two active drags; preserve unknown delivery without replay. """ import argparse +from production_app_smoke import add_provenance_arguments from concurrent.futures import ThreadPoolExecutor import hashlib import json @@ -629,5 +630,5 @@ def release_primary(): parser = argparse.ArgumentParser(description=__doc__) for name in ('driver', 'plugin', 'source', 'primary-grab', 'plan', 'evidence', 'foreground-journal', 'trace-socket'): parser.add_argument('--' + name, required=True, type=Path) - parser.add_argument('--source-sha', required=True) + add_provenance_arguments(parser) raise SystemExit(run(parser.parse_args())) diff --git a/libs/cua-driver/hyprland-plugin/tests/production_desktop_fault_proof.py b/libs/cua-driver/hyprland-plugin/tests/production_desktop_fault_proof.py index 906f1815f7..25e5efaf53 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production_desktop_fault_proof.py +++ b/libs/cua-driver/hyprland-plugin/tests/production_desktop_fault_proof.py @@ -23,6 +23,7 @@ this proof; only execution on the exact native candidate can certify a row. """ import argparse +from production_app_smoke import add_provenance_arguments from concurrent.futures import ThreadPoolExecutor from contextlib import contextmanager import hashlib @@ -709,5 +710,5 @@ def release_primary(): parser = argparse.ArgumentParser(description=__doc__) for name in ('driver', 'plugin', 'source', 'primary-grab', 'plan', 'evidence', 'foreground-journal', 'trace-socket'): parser.add_argument('--' + name, required=True, type=Path) - parser.add_argument('--source-sha', required=True) + add_provenance_arguments(parser) raise SystemExit(run(parser.parse_args())) diff --git a/libs/cua-driver/hyprland-plugin/tests/production_geometry_fault_proof.py b/libs/cua-driver/hyprland-plugin/tests/production_geometry_fault_proof.py index 8072ad8f90..47e10149fd 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production_geometry_fault_proof.py +++ b/libs/cua-driver/hyprland-plugin/tests/production_geometry_fault_proof.py @@ -18,6 +18,7 @@ synthetic orchestration evidence, not native or physical-hardware proof. """ import argparse +from production_app_smoke import add_provenance_arguments from concurrent.futures import ThreadPoolExecutor import hashlib import json @@ -429,5 +430,5 @@ def release_primary(): parser = argparse.ArgumentParser(description=__doc__) for name in ('driver', 'plugin', 'source', 'primary-grab', 'plan', 'evidence', 'foreground-journal', 'trace-socket'): parser.add_argument('--' + name, required=True, type=Path) - parser.add_argument('--source-sha', required=True) + add_provenance_arguments(parser) raise SystemExit(run(parser.parse_args())) diff --git a/libs/cua-driver/hyprland-plugin/tests/production_idle_reconnect_proof.py b/libs/cua-driver/hyprland-plugin/tests/production_idle_reconnect_proof.py index e7bda7c480..bcd3e4341c 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production_idle_reconnect_proof.py +++ b/libs/cua-driver/hyprland-plugin/tests/production_idle_reconnect_proof.py @@ -6,6 +6,7 @@ No transport reset, test input packet, timeout override, or action retry is used. """ import argparse +from production_app_smoke import add_provenance_arguments import hashlib import json import math @@ -325,5 +326,5 @@ def save(name, value): parser = argparse.ArgumentParser(description=__doc__) for name in ('driver', 'plugin', 'source', 'primary-grab', 'plan', 'evidence', 'foreground-journal', 'trace-socket'): parser.add_argument('--' + name, required=True, type=Path) - parser.add_argument('--source-sha', required=True) + add_provenance_arguments(parser) raise SystemExit(run(parser.parse_args())) diff --git a/libs/cua-driver/hyprland-plugin/tests/production_lock_refusal_proof.py b/libs/cua-driver/hyprland-plugin/tests/production_lock_refusal_proof.py index c5fc13e219..d5f7775bd9 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production_lock_refusal_proof.py +++ b/libs/cua-driver/hyprland-plugin/tests/production_lock_refusal_proof.py @@ -18,6 +18,7 @@ Portable tests are orchestration checks, never native certification. """ import argparse +from production_app_smoke import add_provenance_arguments import hashlib import json import os @@ -510,5 +511,5 @@ def release_primary(): for name in ('driver', 'plugin', 'source', 'primary-grab', 'plan', 'evidence', 'foreground-journal', 'trace-socket', 'lock-fixture'): parser.add_argument('--' + name, required=True, type=Path) - parser.add_argument('--source-sha', required=True) + add_provenance_arguments(parser) raise SystemExit(run(parser.parse_args())) diff --git a/libs/cua-driver/hyprland-plugin/tests/production_policy_proof.py b/libs/cua-driver/hyprland-plugin/tests/production_policy_proof.py index be91930477..bb7c3ea0c2 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production_policy_proof.py +++ b/libs/cua-driver/hyprland-plugin/tests/production_policy_proof.py @@ -28,6 +28,7 @@ managed deployment/immutability, or the complete desktop certification matrix. """ import argparse +from production_app_smoke import add_provenance_arguments import base64 import hashlib import json @@ -298,7 +299,8 @@ def client(name, manifest=None, managed=None, mode=None): clients.append(value) return value try: - report['provenance'] = provenance(args) + report['provenance'] = (provenance(args, app_profile=plan['app_profile']) + if 'app_profile' in plan else provenance(args)) report['provenance']['policy_runner_sha256'] = hashlib.sha256(Path(__file__).read_bytes()).hexdigest() report['provenance']['app_process'] = app_process_identity(plan['app'], plan['target']['pid']) windows = json.loads(subprocess.check_output(['hyprctl', '-j', 'clients'], text=True, timeout=10)) @@ -391,5 +393,5 @@ def action(actor, phase, stage, configuration): parser = argparse.ArgumentParser(description=__doc__) for name in ('plan', 'evidence', 'driver', 'plugin', 'source', 'trace-socket'): parser.add_argument('--' + name, type=Path, required=True) - parser.add_argument('--source-sha', required=True) + add_provenance_arguments(parser) raise SystemExit(run(parser.parse_args())) diff --git a/libs/cua-driver/hyprland-plugin/tests/production_primary_conflict_proof.py b/libs/cua-driver/hyprland-plugin/tests/production_primary_conflict_proof.py index 4695b8beb6..a9365bda07 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production_primary_conflict_proof.py +++ b/libs/cua-driver/hyprland-plugin/tests/production_primary_conflict_proof.py @@ -26,6 +26,7 @@ Portable tests are preparation only; native execution is a separate gate. """ import argparse +from production_app_smoke import add_provenance_arguments import hashlib import json import os @@ -395,5 +396,5 @@ def release_primary(): parser = argparse.ArgumentParser(description=__doc__) for name in ('driver', 'plugin', 'source', 'primary-grab', 'plan', 'evidence', 'foreground-journal', 'trace-socket'): parser.add_argument('--' + name, required=True, type=Path) - parser.add_argument('--source-sha', required=True) + add_provenance_arguments(parser) raise SystemExit(run(parser.parse_args())) diff --git a/libs/cua-driver/hyprland-plugin/tests/production_realapp_proof.py b/libs/cua-driver/hyprland-plugin/tests/production_realapp_proof.py index 953bd48267..af194855ec 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production_realapp_proof.py +++ b/libs/cua-driver/hyprland-plugin/tests/production_realapp_proof.py @@ -9,6 +9,7 @@ import hashlib import io import json +import math import os from pathlib import Path import select @@ -20,7 +21,8 @@ from driver_input_live import state, wait_for, wm from primary_trace import Trace, analyze -from production_app_smoke import EXECUTABLES, ground, package_owner, provenance as runtime_provenance +from production_app_smoke import (EXECUTABLES, NS, add_provenance_arguments, digest, ground, package_owner, profile_packages, + provenance as runtime_provenance) from production_mcp import DirectMCP, assert_distinct_runtimes, stop_process import production_pointer_grounding as pointer_grounding from realapp_proof import cleanup_all, rect_position, released_synthetic_input @@ -47,6 +49,12 @@ def manifest_tool_messages(tool): def validate_plan(plan): + app_profile = plan.get('app_profile', 'calc-inkscape') + profile_packages(app_profile) + inkscape_only = app_profile == 'inkscape-only' + if inkscape_only: + assert all(spec['app'] == 'inkscape' for spec in plan['agents']), \ + 'inkscape-only profile requires canonical Inkscape targets' assert plan['purpose'] in ('apps', 'policy', 'policy_cache', 'negative_control', 'capacity') assert type(plan.get('moving_primary', False)) is bool assert not (plan.get('moving_primary') and plan['purpose'] == 'negative_control'), \ @@ -92,7 +100,7 @@ def validate_plan(plan): if capacity: assert not plan.get('moving_primary'), 'capacity requires a parked primary' assert not plan.get('require_overlap'), 'capacity establishes persistent lanes serially' - assert {spec['app'] for spec in plan['agents'][:2]} == {'calc', 'inkscape'} + assert {spec['app'] for spec in plan['agents'][:2]} == ({'inkscape'} if inkscape_only else {'calc', 'inkscape'}) assert all(spec['app'] in ('calc', 'inkscape') for spec in plan['agents']) assert len(plan['phases']) == 3, 'capacity needs two admissions and one refusal' for index, step in enumerate(plan['phases']): @@ -101,7 +109,7 @@ def validate_plan(plan): assert step.get('expect', {'kind': 'dispatched'}) == expected, 'incorrect capacity expectation' if plan['purpose'] == 'apps': assert len(plan['agents']) == 2 - assert {spec['app'] for spec in plan['agents']} == {'calc', 'inkscape'} + assert {spec['app'] for spec in plan['agents']} == ({'inkscape'} if inkscape_only else {'calc', 'inkscape'}) if episode and episode['name'] != 'save': assert plan.get('outputs', []) == [], 'intermediate pointer episodes do not save outputs' else: @@ -112,8 +120,27 @@ def validate_plan(plan): for target in targets: assert set(target) == {'pid', 'window_id'} assert type(target['pid']) is int and target['pid'] > 0 - if capacity or policy_cache: + if capacity or policy_cache or inkscape_only: assert type(target['window_id']) is int and target['window_id'] > 0 + if inkscape_only: + assert len({target['window_id'] for target in targets}) == len(targets), 'apps must be distinct native clients' + documents = [Path(spec['document']) for spec in plan['agents']] + assert all(path.is_absolute() and path.suffix == '.svg' for path in documents), \ + 'Inkscape targets require absolute synthetic SVG document paths' + assert len({path.resolve() for path in documents}) == len(documents), 'each target needs a distinct document' + outputs = plan.get('outputs', []) + assert len({Path(oracle['path']).resolve() for oracle in outputs}) == len(outputs), \ + 'each Inkscape lane needs its own saved SVG' + for oracle in outputs: + assert oracle.get('format') == 'svg' and not oracle.get('zip_member'), 'need plain saved SVG oracles' + assert Path(oracle['path']).suffix == '.svg' and oracle.get('rect_translation'), \ + 'need a saved SVG rectangle translation oracle per lane' + assert all(type(value) in (int, float) and math.isfinite(value) + for bounds in oracle['rect_translation'] for value in bounds), 'invalid SVG translation bounds' + assert any(low > 0 or high < 0 for low, high in oracle['rect_translation']), \ + 'saved SVG oracle must require actual rectangle movement' + assert Path(oracle['path']).resolve() == documents[oracle['agent']].resolve(), \ + 'saved SVG oracle does not belong to its target lane' assert plan['phases'], 'empty plan cannot pass' for phase in plan['phases']: if phase.get('negative_control'): @@ -290,6 +317,25 @@ def verify_capacity(actions): 'refused_agent': 2, 'reason': 'lane_busy'} +def capacity_reservations(status, lanes, previous): + assert status['state'] == 'input_v3_candidate' + assert status['input']['protocol'] == 3 and status['input']['test_only'] is False + assert status['input']['transport_ready'] is True + values = status['input']['lanes'] + assert len(values) == 2 and {row['lane'] for row in values} == {0, 1} + values = {row['lane'] + 1: row for row in values} + retained = {} + for lane in lanes: + row = values[lane] + assert row['reserved'] is True, 'capacity owner lost its lane reservation' + assert row['lease_active'] is False and row['drag_active'] is False + assert row['held_button'] == 0 and row['held_keys'] == 0 + retained[lane] = {key: row[key] for key in ('epoch', 'desktop_generation')} + if lane in previous: + assert retained[lane] == previous[lane], 'capacity lane ownership changed' + return retained + + def check_manifest_refusal(response, expected, tool): content = response.get('structuredContent', {}) assert expected['kind'] == 'refused' and response.get('isError') is True @@ -408,13 +454,18 @@ def document_root(content, oracle): if oracle.get('zip_member'): with zipfile.ZipFile(io.BytesIO(content)) as archive: content = archive.read(oracle['zip_member']) - return ET.fromstring(content) + root = ET.fromstring(content) + if oracle.get('format') == 'svg': + assert root.tag == f"{{{NS['svg']}}}svg", 'saved output is not a native SVG document' + return root def verify_output(before, after, oracle): assert before != after, 'application did not save a changed file' node = document_root(after, oracle).find(oracle['xpath'], oracle.get('namespaces', {})) assert node is not None, 'saved document lacks expected node' + if oracle.get('format') == 'svg': + assert node.tag == f"{{{NS['svg']}}}rect" and node.get('id'), 'saved SVG oracle must identify a rectangle' for key, expected in oracle.get('attributes', {}).items(): assert node.get(key) == expected, (key, node.attrib) if 'text' in oracle: @@ -446,6 +497,22 @@ def app_process_identity(app, pid, proc_root=Path('/proc')): 'gtk3_maps': gtk_maps} +def inkscape_client_identity(spec, windows, proc_root=Path('/proc')): + """Bind a prelaunched native client to its reviewed PID, address and SVG.""" + pid = spec['target']['pid'] + matches = [window for window in windows if window.get('pid') == pid] + assert len(matches) == 1 and matches[0].get('xwayland') is False, 'need one exact native window per app' + window = matches[0] + assert int(window['address'], 16) == spec['target']['window_id'], 'native client target mismatch' + document = Path(spec['document']).resolve(strict=True) + assert document.name in window.get('title', ''), 'native client has a different document' + assert str(document).encode() in (proc_root / str(pid) / 'cmdline').read_bytes().split(b'\0'), \ + 'native client process is not bound to the reviewed document' + assert ET.fromstring(document.read_bytes()).tag == f"{{{NS['svg']}}}svg", 'target document is not SVG' + return {**app_process_identity('inkscape', pid, proc_root), + 'hyprland_window': window, 'document': digest(document)} + + def parallel_actions(steps, action): """Retain every outcome and wake siblings if one fails before the barrier. @@ -475,7 +542,8 @@ def guarded(step): def provenance(args, plan): # Reuse exact-source, canonical ALPM and active mapped-plugin checks. # Hashing a file alone does not prove which module the compositor loaded. - origin = runtime_provenance(args) + origin = (runtime_provenance(args, app_profile=plan['app_profile']) + if 'app_profile' in plan else runtime_provenance(args)) assert plan['package_versions'] == origin['packages'], 'package qualification mismatch' files = {'primary-grab': args.primary_grab} for name in ('production_realapp_proof.py', 'production_mcp.py', 'driver_input_live.py', @@ -488,8 +556,10 @@ def provenance(args, plan): pid = spec['target']['pid'] matches = [window for window in windows if window.get('pid') == pid] assert len(matches) == 1 and matches[0].get('xwayland') is False, 'need one exact native window per app' - identities[str(index)] = {**app_process_identity(spec.get('app'), pid), - 'hyprland_window': matches[0]} + identities[str(index)] = (inkscape_client_identity(spec, windows) + if plan.get('app_profile') == 'inkscape-only' else + {**app_process_identity(spec.get('app'), pid), + 'hyprland_window': matches[0]}) origin['files'].update({name: {'path': str(path.resolve()), 'sha256': hashlib.sha256(path.read_bytes()).hexdigest()} for name, path in files.items()}) @@ -522,12 +592,14 @@ def save(name, value): motion_done, motion_ready = threading.Event(), threading.Event() commands, motion_errors, action_intervals = [], [], [] capacity_traces = [] + capacity_owners = {} policy_cache_traces = [] trajectory = None recording = False focus_before = None baseline_outputs = {} report = {'result': 'failed', 'scope': 'native-production-input-proof', + 'app_profile': plan.get('app_profile', 'calc-inkscape'), 'full_desktop_matrix': False, 'actions': [], 'outputs': [], 'continuous_isolation': 'unproven', 'synthetic_cleanup': 'unproven', 'primary_mode': 'moving' if moving else 'parked'} @@ -580,6 +652,8 @@ def action(step, barrier=None): full = smoke_stage is not None or pointer_stage is not None before = snapshot(mcp, spec['target'], spec['name'], full=full, pixels=pointer_stage is not None) assert before['window_bounds'] == spec['bounds'], 'reviewed geometry is stale' + if plan.get('app_profile') == 'inkscape-only': + assert Path(spec['document']).name in before.get('window_title', ''), 'snapshot document mismatch' if smoke_stage is not None: ground(before, spec['app'], smoke_stage) arguments = step['arguments'] @@ -597,6 +671,10 @@ def action(step, barrier=None): expected = step.get('expect', {'kind': 'dispatched'}) if capacity: assert_distinct_runtimes(clients) + if plan.get('app_profile') == 'inkscape-only' and capacity_owners: + status = read_input_status() + save(f'capacity-agent-{index}-owners-before.json', status) + capacity_reservations(status, capacity_owners, capacity_owners) if not policy_cache: trace_before = trace.collect() if trace and (capacity or expected['kind'] == 'refused') else None if capacity: @@ -618,6 +696,8 @@ def action(step, barrier=None): action_intervals.append((action_start, time.monotonic_ns())) mark('action_response', agent=index, response=response.get('structuredContent'), error=response.get('isError', False)) after = snapshot(mcp, spec['target'], spec['name'], full=full, pixels=pointer_stage is not None) + if plan.get('app_profile') == 'inkscape-only': + assert Path(spec['document']).name in after.get('window_title', ''), 'snapshot document mismatch' require_primary_active(grab, primary_deadline_ns) result = (check_manifest_refusal(response, expected, step['tool']) if policy_cache and expected['kind'] == 'refused' else check_response(response, expected)) @@ -685,6 +765,14 @@ def action(step, barrier=None): result['no_dispatch'] = 'verified' if capacity: assert_distinct_runtimes(clients) + if plan.get('app_profile') == 'inkscape-only': + status = read_input_status() + save(f'capacity-agent-{index}-owners-after.json', status) + lanes = set(capacity_owners) + if expected['kind'] == 'dispatched': + lanes.add(result['compositor_lane']) + capacity_owners.update(capacity_reservations(status, lanes, capacity_owners)) + result['persistent_owners'] = dict(capacity_owners) assert after['window_bounds'] == before['window_bounds'] assert_primary_state(primary_before, wm(), moving) current = state(args.foreground_journal) @@ -901,7 +989,7 @@ def join_motion(): parser = argparse.ArgumentParser(description=__doc__) for name in ('driver', 'plugin', 'source', 'primary-grab', 'plan', 'evidence', 'foreground-journal'): parser.add_argument('--' + name, required=True, type=Path) - parser.add_argument('--source-sha', required=True) + add_provenance_arguments(parser) parser.add_argument('--trace-socket', type=Path) parser.add_argument('--record-video', action='store_true') raise SystemExit(run(parser.parse_args())) diff --git a/libs/cua-driver/hyprland-plugin/tests/production_realapp_proof_test.py b/libs/cua-driver/hyprland-plugin/tests/production_realapp_proof_test.py index 38ff0b3f1c..ac60ff0085 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production_realapp_proof_test.py +++ b/libs/cua-driver/hyprland-plugin/tests/production_realapp_proof_test.py @@ -523,11 +523,18 @@ def test_missing_trace_fails_before_process_launch(self): self.assertEqual(result['capacity']['result'], 'unproven') def test_runner_serial_capacity_and_failures_without_replay(self): - for failure in (None, 'same_lane', 'missing_input', 'wrong_refusal', 'dispatch_on_refusal', - 'dead_runtime', 'stale_window', 'transport', 'missing_hook', 'reset_trace'): - with self.subTest(failure=failure), tempfile.TemporaryDirectory() as directory, ExitStack() as stack: + cases = [(app_profile, failure) for app_profile in ('calc-inkscape', 'inkscape-only') + for failure in (None, 'same_lane', 'missing_input', 'wrong_refusal', 'dispatch_on_refusal', + 'dead_runtime', 'stale_window', 'transport', 'missing_hook', 'reset_trace')] + cases += [('inkscape-only', failure) for failure in ('owner_lost', 'owner_changed', 'document_changed')] + for app_profile, failure in cases: + with self.subTest(app_profile=app_profile, failure=failure), tempfile.TemporaryDirectory() as directory, ExitStack() as stack: root = Path(directory) candidate = capacity_plan() + if app_profile == 'inkscape-only': + candidate['app_profile'] = app_profile + for i, spec in enumerate(candidate['agents']): + spec.update(app='inkscape', document=str(root.resolve() / f'lane-{i}.svg')) path = root / 'plan.json' path.write_text(json.dumps(candidate)) args = SimpleNamespace(plan=path, evidence=root / 'evidence', @@ -548,6 +555,7 @@ def tool(index, name, arguments): if name == 'get_window_state': calls.append(('snapshot', index)) return {'structuredContent': {'screenshot_width': 600, + 'window_title': 'unrelated.svg' if failure == 'document_changed' else f'lane-{index}.svg', 'window_bounds': candidate['agents'][0]['bounds']}} if name == 'get_desktop_state': return {'structuredContent': {'screen_width': 800, 'screen_height': 800}} @@ -582,7 +590,16 @@ def collect(): return {**trace(*events), 'active': events[-1] != STOP, 'hook': failure != 'missing_hook'} trace_client.collect.side_effect = collect grab = Mock(poll=Mock(return_value=None)) + def input_status(): + third_called = ('click', 2) in calls + return {'state': 'input_v3_candidate', 'input': {'protocol': 3, 'test_only': False, + 'transport_ready': True, 'lanes': [ + {'lane': lane, 'reserved': not (third_called and failure == 'owner_lost'), + 'epoch': 100 if third_called and failure == 'owner_changed' else 50 + lane, + 'desktop_generation': 1, 'lease_active': False, 'drag_active': False, + 'held_keys': 0, 'held_button': 0} for lane in (0, 1)]}} replacements = {'provenance': Mock(return_value={}), + 'read_input_status': input_status, 'DirectMCP': Mock(side_effect=agents + [observer]), 'Trace': Mock(return_value=trace_client), 'subprocess.Popen': Mock(return_value=grab), 'primary_acknowledgement': Mock(return_value='HELD\n'), @@ -616,6 +633,10 @@ def collect(): self.assertEqual(result['synthetic_cleanup'], 'verified') for i in range(3): self.assertTrue((args.evidence / f'capacity-agent-{i}-trace.json').is_file()) + if app_profile == 'inkscape-only': + self.assertEqual(set(result['actions'][2]['persistent_owners']), {'1', '2'}) + self.assertTrue((args.evidence / 'capacity-agent-2-owners-before.json').is_file()) + self.assertTrue((args.evidence / 'capacity-agent-2-owners-after.json').is_file()) class PlanTests(unittest.TestCase): diff --git a/libs/cua-driver/hyprland-plugin/tests/production_session_fault_proof.py b/libs/cua-driver/hyprland-plugin/tests/production_session_fault_proof.py index 58198ed0ed..9cf380b916 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production_session_fault_proof.py +++ b/libs/cua-driver/hyprland-plugin/tests/production_session_fault_proof.py @@ -24,6 +24,7 @@ becomes a skipped/passing row. Portable tests are not native certification. """ import argparse +from production_app_smoke import add_provenance_arguments from concurrent.futures import ThreadPoolExecutor from contextlib import contextmanager import fcntl @@ -585,5 +586,5 @@ def release_primary(): parser = argparse.ArgumentParser(description=__doc__) for name in ('driver', 'plugin', 'source', 'primary-grab', 'plan', 'evidence', 'foreground-journal', 'trace-socket'): parser.add_argument('--' + name, required=True, type=Path) - parser.add_argument('--source-sha', required=True) + add_provenance_arguments(parser) raise SystemExit(run(parser.parse_args())) diff --git a/libs/cua-driver/hyprland-plugin/tests/production_target_lifetime_proof.py b/libs/cua-driver/hyprland-plugin/tests/production_target_lifetime_proof.py index ec3c02c87b..6d8ae30650 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production_target_lifetime_proof.py +++ b/libs/cua-driver/hyprland-plugin/tests/production_target_lifetime_proof.py @@ -24,6 +24,7 @@ No app launches, signer material, policy changes, production edits or replay. """ import argparse +from production_app_smoke import add_provenance_arguments from concurrent.futures import ThreadPoolExecutor import hashlib import json @@ -495,5 +496,5 @@ def release(): parser = argparse.ArgumentParser(description=__doc__) for name in ('driver', 'plugin', 'source', 'primary-grab', 'plan', 'evidence', 'foreground-journal', 'trace-socket'): parser.add_argument('--' + name, required=True, type=Path) - parser.add_argument('--source-sha', required=True) + add_provenance_arguments(parser) raise SystemExit(run(parser.parse_args())) From d66f191fdc0c39b15f969f0790cb1b504f7d431d Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Wed, 9 Sep 2026 21:31:00 -0500 Subject: [PATCH 03/27] build(cua-driver): pin measured Omarchy profile and header selection --- .../packaging/release/PROFILE-PKGBUILD.in | 2 + .../packaging/release/PROFILE-USAGE.md | 14 +++ .../packaging/release/profile_verify.py | 112 +++++++++++++++++- .../profiles/omarchy-stable-20260910.json | 39 ++++++ .../packaging/release/test_profile_release.py | 101 +++++++++++++++- 5 files changed, 260 insertions(+), 8 deletions(-) create mode 100644 libs/cua-driver/hyprland-plugin/packaging/release/profiles/omarchy-stable-20260910.json diff --git a/libs/cua-driver/hyprland-plugin/packaging/release/PROFILE-PKGBUILD.in b/libs/cua-driver/hyprland-plugin/packaging/release/PROFILE-PKGBUILD.in index fd095c1262..f28b35630e 100644 --- a/libs/cua-driver/hyprland-plugin/packaging/release/PROFILE-PKGBUILD.in +++ b/libs/cua-driver/hyprland-plugin/packaging/release/PROFILE-PKGBUILD.in @@ -37,6 +37,8 @@ build() { _verify || return 1 cmake -S "$srcdir/$_stem" -B "$srcdir/build" -G Ninja \ -DCMAKE_BUILD_TYPE=Release -DCMAKE_CXX_COMPILER="$_cxx" \ + -DPKG_CONFIG_EXECUTABLE=/usr/bin/pkgconf -DPKG_CONFIG_ARGN= \ + -DPKG_CONFIG_USE_CMAKE_PREFIX_PATH=OFF -DCMAKE_PREFIX_PATH= \ -DBUILD_TESTING=ON -DCUA_HYPRLAND_BUILD_PLUGIN=ON \ -DCUA_HYPRLAND_EXPECTED_VERSION=0.56.2 \ -DCUA_HYPRLAND_INPUT=ON -DCUA_HYPRLAND_TEST_INPUT=OFF \ diff --git a/libs/cua-driver/hyprland-plugin/packaging/release/PROFILE-USAGE.md b/libs/cua-driver/hyprland-plugin/packaging/release/PROFILE-USAGE.md index c5208dc781..fbe3d17e24 100644 --- a/libs/cua-driver/hyprland-plugin/packaging/release/PROFILE-USAGE.md +++ b/libs/cua-driver/hyprland-plugin/packaging/release/PROFILE-USAGE.md @@ -43,6 +43,20 @@ headers. CMake enables production input, disables experimental input and tracing and builds the bundled tests. Packaging runs CTest even with `--nocheck` or `--repackage`; skipping makepkg integrity checks does not skip the recipe checks. +Build with the system `/usr/bin/pkgconf` and package-owned +`/usr/share/pkgconfig/hyprland.pc`. The canonical Hyprland header tree must lead +pkg-config's include selection: its hashed `protocols` directory may precede +the root, and the root must precede any external include directory. Other +include roots must be real paths under `/usr/include`. Clear pkg-config/CMake +routing overrides, compiler include-path +variables such as `CPATH` and `CPLUS_INCLUDE_PATH`, and flags that inject include +paths, headers, sysroots, toolchains or response files. Ordinary makepkg +optimization and hardening flags remain supported. The verifier refuses these +overrides instead of silently discarding them. It records the actual pkgconf +executable, `.pc` file digests and flags in build provenance, and checks CMake's +cached Hyprland flags against that same canonical selection. These are targeted +build-selection checks, not a sandbox for arbitrary build environments. + ## Qualify package transactions In a disposable matching Arch environment, with ordinary-user build tools and diff --git a/libs/cua-driver/hyprland-plugin/packaging/release/profile_verify.py b/libs/cua-driver/hyprland-plugin/packaging/release/profile_verify.py index 02671b73c1..6024e95f7f 100644 --- a/libs/cua-driver/hyprland-plugin/packaging/release/profile_verify.py +++ b/libs/cua-driver/hyprland-plugin/packaging/release/profile_verify.py @@ -8,6 +8,7 @@ from pathlib import Path, PurePosixPath import platform import re +import shlex import subprocess import tarfile import tempfile @@ -17,6 +18,18 @@ STEM = f"cua-hyprland-plugin-{DRIVER_VERSION}-{SOURCE_REVISION}" OPTIONS = {"CUA_HYPRLAND_INPUT": "ON", "CUA_HYPRLAND_TEST_INPUT": "OFF", "CUA_HYPRLAND_INPUT_TRACE": "OFF"} TOOLING = ("profile_bundle.py", "profile_verify.py", "PROFILE-PKGBUILD.in", "PROFILE-USAGE.md", "lifecycle.py") +PKGCONF = Path("/usr/bin/pkgconf") +HYPRLAND_PC = Path("/usr/share/pkgconfig/hyprland.pc") +SYSTEM_INCLUDE = Path("/usr/include") +HEADER_ROOT = SYSTEM_INCLUDE / "hyprland" +BUILD_ROUTING_ENV = {"CPATH", "CPLUS_INCLUDE_PATH", "C_INCLUDE_PATH", "OBJC_INCLUDE_PATH", + "GCC_EXEC_PREFIX", "COMPILER_PATH", "LIBRARY_PATH", "SDKROOT", "SYSROOT"} +EMPTY_CMAKE_ROUTING = ("CMAKE_PREFIX_PATH", "CMAKE_MODULE_PATH", "CMAKE_TOOLCHAIN_FILE", "CMAKE_SYSROOT", + "CMAKE_SYSROOT_COMPILE", "CMAKE_SYSROOT_LINK", "CMAKE_FIND_ROOT_PATH", + "CMAKE_CXX_COMPILER_EXTERNAL_TOOLCHAIN", "CMAKE_CXX_COMPILER_LAUNCHER", + "CMAKE_CXX_LINKER_LAUNCHER", "CMAKE_PROJECT_INCLUDE", "CMAKE_PROJECT_INCLUDE_BEFORE", + "CMAKE_PROJECT_TOP_LEVEL_INCLUDES", "CMAKE_USER_MAKE_RULES_OVERRIDE", "CMAKE_USER_MAKE_RULES_OVERRIDE_CXX", + "CMAKE_CXX_COMPILER_TARGET", "CMAKE_CXX_STANDARD_INCLUDE_DIRECTORIES") def require(condition, message): @@ -199,9 +212,28 @@ def elf_comment(binary, expected): def linked_runtime(binary, profile): dynamic = run("readelf", "-d", str(binary)) require("Shared library: [libstdc++.so.6]" in dynamic and "Shared library: [libc++.so" not in dynamic, "binary must use shared libstdc++") - matches = re.findall(r"^\s*libstdc\+\+\.so\.6 => (/\S+) \(", run("ldd", str(binary)), re.MULTILINE) - require(len(matches) == 1, "cannot resolve shared libstdc++") - runtime = Path(matches[0]).resolve(strict=True) + resolved = {} + for line in run("ldd", str(binary)).splitlines(): + # ldd may exit zero even when a different required library is missing. + # Reject unresolved dependencies and diagnostics, not just a missing C++ runtime. + match = re.fullmatch(r"\s*(\S+) => (/\S+) \(0x[0-9a-fA-F]+\)\s*", line) + if match: + name, path = match.groups() + else: + match = re.fullmatch(r"\s*(/\S+) \(0x[0-9a-fA-F]+\)\s*", line) + if match: + path = match[1] + name = Path(path).name + else: + require(re.fullmatch(r"\s*linux-(?:vdso|gate)\.so\.[0-9]+ \(0x[0-9a-fA-F]+\)\s*", line), + "unresolved or malformed shared dependency: " + line.strip()) + continue + require(name not in resolved, "duplicate shared dependency resolution") + resolved[name] = path + needed = re.findall(r"Shared library: \[([^]]+)\]", dynamic) + require(set(needed) <= set(resolved), "missing shared dependency resolution") + require("libstdc++.so.6" in resolved, "cannot resolve shared libstdc++") + runtime = Path(resolved["libstdc++.so.6"]).resolve(strict=True) require(runtime.name == profile["runtime"]["basename"] and digest(runtime) == profile["runtime"]["sha256"], "loaded shared runtime mismatch") require(run("pacman", "-Qoq", str(runtime)) in profile["runtime"]["packages"], "shared runtime owner is not pinned by profile") return digest(runtime) @@ -219,10 +251,11 @@ def verify_environment(profile): def verify_native(cxx, profile): + verify_build_environment() runtime_sha = verify_environment(profile) require(cxx.is_absolute() and cxx.is_file(), "C++ compiler must be an existing absolute path") require(digest(cxx) == profile["compiler"]["sha256"], "compiler checksum mismatch") - require(run("pkg-config", "--modversion", "hyprland") == profile["hyprland"]["header_version"], "Hyprland header mismatch") + pkgconfig = pkgconfig_selection(profile) require(header_inventory_sha256() == profile["hyprland"]["headers_sha256"], "Hyprland header inventory mismatch") macros = run(str(cxx), "-dM", "-E", "-x", "c++", "-", input="") require(f'#define __VERSION__ "{profile["compiler"]["version"]}"' in macros.splitlines() and not re.search(r"^#define __clang__\b", macros, re.MULTILINE), "GCC version/date mismatch") @@ -234,7 +267,61 @@ def verify_native(cxx, profile): require(runtime.name == profile["runtime"]["basename"] and digest(runtime) == runtime_sha, "compiler shared runtime mismatch") return {"compiler_sha256": digest(cxx), "compiler_version": profile["compiler"]["version"], "compiler_probe_comment": profile["compiler"]["comment"], "compiler_runtime_sha256": runtime_sha, - "compositor_sha256": profile["hyprland"]["sha256"], "compositor_runtime_sha256": runtime_sha} + "compositor_sha256": profile["hyprland"]["sha256"], "compositor_runtime_sha256": runtime_sha, + "pkgconfig": pkgconfig} + + +def verify_flags(flags, label): + # Keep normal makepkg optimization/hardening flags, but refuse options that + # inject headers, an alternate toolchain, or hidden response-file arguments. + routing = ("-I", "-L", "-B", "-isystem", "-iquote", "-idirafter", "-iprefix", "-iwithprefix", + "-include", "-imacros", "-isysroot", "--sysroot", "-nostdinc", "-specs", "--specs", + "-fplugin", "-wrapper", "-Xpreprocessor", "-Xclang", "-Xlinker", "--library-path", + "-rpath", "--rpath", "--gcc-toolchain", "-gcc-toolchain", "-resource-dir") + for flag in flags: + arguments = flag[4:].split(",") if flag.startswith(("-Wp,", "-Wl,")) else [flag] + for argument in arguments: + require(not argument.startswith(("@", *routing)), f"header/toolchain flag override refused: {label}") + + +def verify_build_environment(): + for name, value in os.environ.items(): + routed = (name in BUILD_ROUTING_ENV or name.startswith(("PKG_CONFIG", "PKGCONF")) or + (name.startswith("CMAKE_") and name != "CMAKE_BUILD_PARALLEL_LEVEL")) + require(not value or not routed, f"build routing environment refused: {name}") + for name in ("CFLAGS", "CXXFLAGS", "CPPFLAGS", "LDFLAGS"): + verify_flags(shlex.split(os.environ.get(name, "")), name) + + +def pkgconfig_selection(profile): + require(PKGCONF.is_file(), "canonical /usr/bin/pkgconf is required") + require(run("pacman", "-Qoq", str(PKGCONF)) == "pkgconf", "pkgconf executable owner mismatch") + require(run(str(PKGCONF), "--variable=pcfiledir", "hyprland") == str(HYPRLAND_PC.parent), "noncanonical Hyprland pkg-config source") + require(HYPRLAND_PC.is_file() and not HYPRLAND_PC.is_symlink() and HYPRLAND_PC.resolve() == HYPRLAND_PC, + "Hyprland pkg-config source must be canonical") + require(run("pacman", "-Qoq", str(HYPRLAND_PC)) == "hyprland", "Hyprland pkg-config owner mismatch") + require(run(str(PKGCONF), "--modversion", "hyprland") == profile["hyprland"]["header_version"], "Hyprland header mismatch") + cflags = shlex.split(run(str(PKGCONF), "--cflags", "hyprland")) + includes = shlex.split(run(str(PKGCONF), "--cflags-only-I", "hyprland")) + other = shlex.split(run(str(PKGCONF), "--cflags-only-other", "hyprland")) + require(all(flag.startswith("-I") and len(flag) > 2 for flag in includes), "unexpected pkg-config include flags") + include_dirs = [flag[2:] for flag in includes] + # The source includes . Native Hyprland puts its hashed protocols + # directory before the root, then src. Require the whole leading selection + # through the root to stay inside that hashed tree, before any external root. + require(str(HEADER_ROOT) in include_dirs, "canonical Hyprland header root is missing") + leading = include_dirs[:include_dirs.index(str(HEADER_ROOT)) + 1] + require(all(Path(name).is_relative_to(HEADER_ROOT) for name in leading), "Hyprland headers are not first in pkg-config include selection") + for name in include_dirs: + path = Path(name) + require(path.is_dir() and path.resolve() == path and path.is_relative_to(SYSTEM_INCLUDE), "noncanonical pkg-config include path") + require([flag for flag in cflags if flag.startswith("-I")] == includes and + [flag for flag in cflags if not flag.startswith("-I")] == other, "inconsistent pkg-config flags") + verify_flags(other, "pkg-config CFLAGS_OTHER") + libraries = shlex.split(run(str(PKGCONF), "--libs", "hyprland")) + return {"executable": str(PKGCONF), "executable_sha256": digest(PKGCONF), + "pc_path": str(HYPRLAND_PC), "pc_sha256": digest(HYPRLAND_PC), + "cflags": cflags, "include_dirs": include_dirs, "cflags_other": other, "ldflags": libraries} def header_inventory_sha256(root=Path("/usr/include/hyprland")): @@ -252,6 +339,8 @@ def header_inventory_sha256(root=Path("/usr/include/hyprland")): def verify_build(build, source, cxx, profile): + verify_build_environment() + pkgconfig = pkgconfig_selection(profile) cache = {} for line in (build / "CMakeCache.txt").read_text().splitlines(): match = re.match(r"([^:#/][^:]*):[^=]+=(.*)", line) @@ -260,11 +349,24 @@ def verify_build(build, source, cxx, profile): cache[match[1]] = match[2] expected = dict(OPTIONS, BUILD_TESTING="ON", CUA_HYPRLAND_BUILD_PLUGIN="ON", CMAKE_BUILD_TYPE="Release", CMAKE_GENERATOR="Ninja", + PKG_CONFIG_EXECUTABLE=str(PKGCONF), PKG_CONFIG_ARGN="", PKG_CONFIG_USE_CMAKE_PREFIX_PATH="OFF", + HYPRLAND_VERSION=profile["hyprland"]["header_version"], + HYPRLAND_CFLAGS=";".join(pkgconfig["cflags"]), + HYPRLAND_INCLUDE_DIRS=";".join(pkgconfig["include_dirs"]), + HYPRLAND_CFLAGS_OTHER=";".join(pkgconfig["cflags_other"]), + HYPRLAND_LDFLAGS=";".join(pkgconfig["ldflags"]), CUA_HYPRLAND_EXPECTED_VERSION=profile["hyprland"]["header_version"], CUA_HYPRLAND_TEST_OPERATOR_KEY="", CMAKE_CXX_COMPILER=str(cxx), CMAKE_HOME_DIRECTORY=str(source.resolve())) for name, value in expected.items(): require(cache.get(name) == value, f"build configuration mismatch: {name}") + for name in EMPTY_CMAKE_ROUTING: + require(not cache.get(name), f"CMake routing override refused: {name}") + for name, value in cache.items(): + if name.startswith("CMAKE_PROJECT_") and name.endswith(("_INCLUDE", "_INCLUDE_BEFORE", "_TOP_LEVEL_INCLUDES")): + require(not value, f"CMake routing override refused: {name}") + if name.startswith(("CMAKE_CXX_FLAGS", "CMAKE_EXE_LINKER_FLAGS", "CMAKE_MODULE_LINKER_FLAGS", "CMAKE_SHARED_LINKER_FLAGS")): + verify_flags(shlex.split(value), name) module = build / "cua-hyprland-plugin.so" elf_comment(module, profile["compiler"]["comment"]) linked_runtime(module, profile) diff --git a/libs/cua-driver/hyprland-plugin/packaging/release/profiles/omarchy-stable-20260910.json b/libs/cua-driver/hyprland-plugin/packaging/release/profiles/omarchy-stable-20260910.json new file mode 100644 index 0000000000..8bc2e4f00f --- /dev/null +++ b/libs/cua-driver/hyprland-plugin/packaging/release/profiles/omarchy-stable-20260910.json @@ -0,0 +1,39 @@ +{ + "schema": 1, + "profile_id": "omarchy-stable-20260910", + "kit_version": "1.0.0", + "package_release": 2, + "source": { + "revision": "4b3396d9fe4bd3cf723b0eb8db83c18a8764b520", + "driver_version": "0.24.0", + "archive_sha256": "73b65823b3281c027a31cd8f7d9ca9586fe7386ed1eec74174f2e72cea0af643", + "manifest_sha256": "fb5b5710218afecfa54e9803e8e95f4702e8a47ac4108abd06b4f5f3c1032eeb" + }, + "architecture": "x86_64", + "hyprland": { + "package_version": "0.56.2-2", + "header_version": "0.56.2", + "headers_sha256": "88a6875af00203627b264a5c1f9908781be4ad8d9cee4e577fef174e72dd0e28", + "sha256": "da8fcacf347bcbed83edc40108c6e2298da095e22246bd764e9bb382786cebb2" + }, + "compiler": { + "version": "16.2.1 20260810", + "comment": "GCC: (GNU) 16.2.1 20260810", + "sha256": "f04191f6a7b2cd7d9a62e1745872b8a6088791e5af6955488c69c9b2c4668bc9" + }, + "runtime": { + "basename": "libstdc++.so.6.0.36", + "sha256": "f5fc7380f2ae46fa4053a64be04e7b98109f1066a4bbfff3c37042488aa0be0e", + "packages": { + "aquamarine": "0.15.0-2", + "glibc": "2.44+r24+g16be1518495f-1", + "hyprcursor": "0.1.13-7", + "hyprgraphics": "0.5.1-4", + "hyprlang": "0.6.8-5", + "hyprutils": "0.14.2-1", + "libgcc": "16.2.1+r23+gd564253eb6c8-1", + "libstdc++": "16.2.1+r23+gd564253eb6c8-1", + "wayland": "1.26.0-1" + } + } +} diff --git a/libs/cua-driver/hyprland-plugin/packaging/release/test_profile_release.py b/libs/cua-driver/hyprland-plugin/packaging/release/test_profile_release.py index 40269b8b16..dd2ca12ffa 100644 --- a/libs/cua-driver/hyprland-plugin/packaging/release/test_profile_release.py +++ b/libs/cua-driver/hyprland-plugin/packaging/release/test_profile_release.py @@ -316,6 +316,10 @@ def setUp(self): self.calls = [] self.overrides = {} self.probe_comment = None + self.pkgconfig = {"executable": "/usr/bin/pkgconf", "executable_sha256": "e" * 64, + "pc_path": "/usr/share/pkgconfig/hyprland.pc", "pc_sha256": "f" * 64, + "cflags": ["-I/usr/include/hyprland", "-pthread"], + "include_dirs": ["/usr/include/hyprland"], "cflags_other": ["-pthread"], "ldflags": ["-lhyprutils"]} def fake_run(self, *args, input=None): self.calls.append(args) @@ -326,7 +330,7 @@ def fake_run(self, *args, input=None): return args[2] + " " + versions[args[2]] if args[:2] == ("pacman", "-Qoq"): return "gcc-libs" - if args[0] == "pkg-config": + if args[0] == str(verify.PKGCONF): return "0.56.2" if args[:3] == ("readelf", "-p", ".comment"): if args[-1].endswith("probe.o") and self.probe_comment: @@ -343,10 +347,15 @@ def fake_run(self, *args, input=None): return "" def native_context(self): + def fake_selection(profile): + verify.require(verify.run(str(verify.PKGCONF), "--modversion", "hyprland") == profile["hyprland"]["header_version"], "Hyprland header mismatch") + return self.pkgconfig patches = [mock.patch.object(verify.platform, "system", return_value="Linux"), mock.patch.object(verify.platform, "machine", return_value="x86_64"), mock.patch.object(verify, "run", side_effect=self.fake_run), - mock.patch.object(verify, "header_inventory_sha256", return_value="d" * 64)] + mock.patch.object(verify, "header_inventory_sha256", return_value="d" * 64), + mock.patch.object(verify, "pkgconfig_selection", side_effect=fake_selection), + mock.patch.dict(verify.os.environ, {}, clear=True)] real_digest = verify.digest patches.append(mock.patch.object(verify, "digest", side_effect=lambda path: self.profile["hyprland"]["sha256"] if str(path) == "/usr/bin/Hyprland" else real_digest(path))) for patch in patches: @@ -359,7 +368,7 @@ def test_native_profile_exact_checks_and_refusals(self): self.assertEqual(native["compiler_runtime_sha256"], self.profile["runtime"]["sha256"]) for command, output in ((('pacman', '-Q', 'hyprland'), 'hyprland 0.56.2-1'), (('pacman', '-Q', 'gcc-libs'), 'gcc-libs 0-1'), - (('pkg-config', '--modversion', 'hyprland'), '0.56.3'), + (('/usr/bin/pkgconf', '--modversion', 'hyprland'), '0.56.3'), ((str(self.cxx), '-dM', '-E', '-x', 'c++', '-'), '#define __VERSION__ "wrong"'), (('readelf', '-p', '.comment', '/usr/bin/Hyprland'), 'wrong comment'), (('readelf', '-d', '/usr/bin/Hyprland'), 'static runtime')): @@ -385,6 +394,24 @@ def test_native_profile_exact_checks_and_refusals(self): with self.assertRaisesRegex(ValueError, "runtime mismatch"): verify.verify_native(self.cxx, self.profile) + def test_missing_or_malformed_non_cpp_dependency_refuses(self): + self.native_context() + valid = f" libstdc++.so.6 => {self.runtime} (0x0)" + for other in ("libhyprutils.so.13 => not found", "unexpected loader diagnostic", + "libc.so.6 => relative/path (0x0)"): + self.overrides[("ldd", "/usr/bin/Hyprland")] = valid + "\n " + other + with self.subTest(other=other), self.assertRaisesRegex(ValueError, "shared dependency"): + verify.verify_environment(self.profile) + self.overrides[("ldd", "/usr/bin/Hyprland")] = valid + self.overrides[("readelf", "-d", "/usr/bin/Hyprland")] = \ + "Shared library: [libstdc++.so.6]\nShared library: [libhyprutils.so.13]" + with self.assertRaisesRegex(ValueError, "missing shared dependency resolution"): + verify.verify_environment(self.profile) + self.overrides.clear() + self.overrides[("ldd", "/usr/bin/Hyprland")] = \ + "linux-vdso.so.1 (0x7fff)\n" + valid + "\n /lib64/ld-linux-x86-64.so.2 (0x7ff0)" + verify.verify_environment(self.profile) + def test_build_configuration_and_shared_runtime_checks(self): self.native_context() source = self.source() @@ -393,6 +420,10 @@ def test_build_configuration_and_shared_runtime_checks(self): (build / "cua-hyprland-plugin.so").write_text("module") expected = dict(verify.OPTIONS, BUILD_TESTING="ON", CUA_HYPRLAND_BUILD_PLUGIN="ON", CMAKE_BUILD_TYPE="Release", CMAKE_GENERATOR="Ninja", + PKG_CONFIG_EXECUTABLE="/usr/bin/pkgconf", PKG_CONFIG_ARGN="", PKG_CONFIG_USE_CMAKE_PREFIX_PATH="OFF", + HYPRLAND_VERSION="0.56.2", HYPRLAND_CFLAGS=";".join(self.pkgconfig["cflags"]), + HYPRLAND_INCLUDE_DIRS=";".join(self.pkgconfig["include_dirs"]), HYPRLAND_CFLAGS_OTHER=";".join(self.pkgconfig["cflags_other"]), + HYPRLAND_LDFLAGS=";".join(self.pkgconfig["ldflags"]), CUA_HYPRLAND_EXPECTED_VERSION="0.56.2", CUA_HYPRLAND_TEST_OPERATOR_KEY="", CMAKE_CXX_COMPILER=str(self.cxx), CMAKE_HOME_DIRECTORY=str(source.resolve())) cache = build / "CMakeCache.txt" @@ -403,6 +434,70 @@ def test_build_configuration_and_shared_runtime_checks(self): cache.write_text("".join(f"{name}:STRING={value}\n" for name, value in changed.items())) with self.subTest(key=key), self.assertRaisesRegex(ValueError, "build configuration mismatch"): verify.verify_build(build, source, self.cxx, self.profile) + for name, value in (("CMAKE_CXX_FLAGS", "-O2 -I/alternate-same-version"), + ("CMAKE_TOOLCHAIN_FILE", "/alternate/toolchain.cmake"), + ("CMAKE_CXX_COMPILER_LAUNCHER", "/alternate/launcher")): + changed = dict(expected, **{name: value}) + cache.write_text("".join(f"{key}:STRING={setting}\n" for key, setting in changed.items())) + with self.subTest(name=name), self.assertRaisesRegex(ValueError, "override refused"): + verify.verify_build(build, source, self.cxx, self.profile) + + def test_header_and_toolchain_environment_overrides_refused(self): + for name in ("PKG_CONFIG_PATH", "PKG_CONFIG_LIBDIR", "PKG_CONFIG_SYSROOT_DIR", "PKG_CONFIG", "PKGCONF_PKG_PKGF", + "CPATH", "CPLUS_INCLUDE_PATH", "GCC_EXEC_PREFIX", "COMPILER_PATH", "LIBRARY_PATH", + "CMAKE_PREFIX_PATH", "CMAKE_TOOLCHAIN_FILE"): + with self.subTest(name=name), mock.patch.dict(verify.os.environ, {name: "/alternate-same-version"}, clear=True), self.assertRaisesRegex(ValueError, "routing environment refused"): + verify.verify_build_environment() + for flags in ("-O2 -I/alternate", "-isystem /alternate", "-include /alternate/header.h", "@/alternate/flags", + "-Wp,-I/alternate", "-B/alternate", "--sysroot=/alternate", "-specs=/alternate/specs", + "-Wl,-rpath,/alternate"): + with self.subTest(flags=flags), mock.patch.dict(verify.os.environ, {"CXXFLAGS": flags}, clear=True), self.assertRaisesRegex(ValueError, "flag override refused"): + verify.verify_build_environment() + with mock.patch.dict(verify.os.environ, {"CXXFLAGS": "-march=x86-64 -O2 -pipe -Wp,-D_FORTIFY_SOURCE=3 -fstack-protector-strong", + "LDFLAGS": "-Wl,-O1,--sort-common,--as-needed,-z,relro,-z,now"}, clear=True): + verify.verify_build_environment() + + def test_canonical_pkgconf_rejects_alternate_same_version_headers(self): + root = self.root.resolve() + system = root / "usr/include" + headers = system / "hyprland" + alternate = system / "alternate-same-version" + headers.mkdir(parents=True) + alternate.mkdir() + pc = root / "usr/share/pkgconfig/hyprland.pc" + pc.parent.mkdir(parents=True) + pc.write_text("canonical metadata fixture") + executable = root / "usr/bin/pkgconf" + executable.parent.mkdir(parents=True) + executable.write_text("pkgconf fixture") + include_flags = f"-I{headers}" + pc_directory = str(pc.parent) + + def fake_run(*args, **kwargs): + if args[:2] == ("pacman", "-Qoq"): + return "pkgconf" if args[2] == str(executable) else "hyprland" + return {"--variable=pcfiledir": pc_directory, "--modversion": "0.56.2", + "--cflags": include_flags + " -pthread", "--cflags-only-I": include_flags, + "--cflags-only-other": "-pthread", "--libs": "-lhyprutils"}[args[1]] + + with mock.patch.object(verify, "PKGCONF", executable), mock.patch.object(verify, "HYPRLAND_PC", pc), \ + mock.patch.object(verify, "HEADER_ROOT", headers), mock.patch.object(verify, "SYSTEM_INCLUDE", system), \ + mock.patch.object(verify, "run", side_effect=fake_run): + selected = verify.pkgconfig_selection(self.profile) + self.assertEqual(selected["pc_sha256"], verify.digest(pc)) + self.assertEqual(selected["include_dirs"], [str(headers)]) + (headers / "protocols").mkdir() + (headers / "src").mkdir() + include_flags = f"-I{headers}/protocols -I{headers} -I{headers}/src" + self.assertEqual(verify.pkgconfig_selection(self.profile)["include_dirs"], + [str(headers / "protocols"), str(headers), str(headers / "src")]) + include_flags = f"-I{alternate} -I{headers}" + with self.assertRaisesRegex(ValueError, "not first"): + verify.pkgconfig_selection(self.profile) + include_flags = f"-I{headers}" + pc_directory = str(root / "alternate-same-version/pkgconfig") + with self.assertRaisesRegex(ValueError, "noncanonical Hyprland pkg-config source"): + verify.pkgconfig_selection(self.profile) def test_consumer_does_not_invoke_compiler_or_headers_and_refuses_drift(self): self.native_context() From 5b2f5066f19a18cbb67aa61e5fd63cb3cf2e98f9 Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Wed, 9 Sep 2026 21:35:36 -0500 Subject: [PATCH 04/27] build(cua-driver): match native CMake pkgconf query semantics --- .../packaging/release/PROFILE-USAGE.md | 7 +++- .../packaging/release/profile_verify.py | 35 +++++++++++++------ .../profiles/omarchy-stable-20260910.json | 1 + .../packaging/release/test_profile_release.py | 32 +++++++++++++++-- 4 files changed, 61 insertions(+), 14 deletions(-) diff --git a/libs/cua-driver/hyprland-plugin/packaging/release/PROFILE-USAGE.md b/libs/cua-driver/hyprland-plugin/packaging/release/PROFILE-USAGE.md index fbe3d17e24..121b028a47 100644 --- a/libs/cua-driver/hyprland-plugin/packaging/release/PROFILE-USAGE.md +++ b/libs/cua-driver/hyprland-plugin/packaging/release/PROFILE-USAGE.md @@ -54,7 +54,12 @@ paths, headers, sysroots, toolchains or response files. Ordinary makepkg optimization and hardening flags remain supported. The verifier refuses these overrides instead of silently discarding them. It records the actual pkgconf executable, `.pc` file digests and flags in build provenance, and checks CMake's -cached Hyprland flags against that same canonical selection. These are targeted +cached Hyprland flags against that same canonical selection. Queries use CMake's +fixed `PKG_CONFIG_ALLOW_SYSTEM_CFLAGS=1` and `PKG_CONFIG_ALLOW_SYSTEM_LIBS=1` +semantics, preserving system `-I/usr/include` and `-L/usr/lib` flags for exact +cache comparison. Only the exact leading `/usr/include` system root is admitted +before the hashed Hyprland directories; an alternate `/usr/include/src` tree is +refused. Caller-supplied pkg-config overrides are still rejected. These are targeted build-selection checks, not a sandbox for arbitrary build environments. ## Qualify package transactions diff --git a/libs/cua-driver/hyprland-plugin/packaging/release/profile_verify.py b/libs/cua-driver/hyprland-plugin/packaging/release/profile_verify.py index 6024e95f7f..e90ea69623 100644 --- a/libs/cua-driver/hyprland-plugin/packaging/release/profile_verify.py +++ b/libs/cua-driver/hyprland-plugin/packaging/release/profile_verify.py @@ -19,6 +19,7 @@ OPTIONS = {"CUA_HYPRLAND_INPUT": "ON", "CUA_HYPRLAND_TEST_INPUT": "OFF", "CUA_HYPRLAND_INPUT_TRACE": "OFF"} TOOLING = ("profile_bundle.py", "profile_verify.py", "PROFILE-PKGBUILD.in", "PROFILE-USAGE.md", "lifecycle.py") PKGCONF = Path("/usr/bin/pkgconf") +PKGCONF_ENV = {"PKG_CONFIG_ALLOW_SYSTEM_CFLAGS": "1", "PKG_CONFIG_ALLOW_SYSTEM_LIBS": "1"} HYPRLAND_PC = Path("/usr/share/pkgconfig/hyprland.pc") SYSTEM_INCLUDE = Path("/usr/include") HEADER_ROOT = SYSTEM_INCLUDE / "hyprland" @@ -198,9 +199,9 @@ def render_recipe(template, profile, provenance): return template.encode() -def run(*command, input=None): +def run(*command, input=None, extra_env=None): return subprocess.check_output(command, input=input, text=True, stderr=subprocess.PIPE, - env={**os.environ, "LC_ALL": "C"}).strip() + env={**os.environ, **(extra_env or {}), "LC_ALL": "C"}).strip() def elf_comment(binary, expected): @@ -294,23 +295,36 @@ def verify_build_environment(): def pkgconfig_selection(profile): + def query(argument): + # FindPkgConfig retains system -I/-L flags in its cache. Query with the + # same fixed semantics, without accepting caller pkg-config overrides. + return run(str(PKGCONF), argument, "hyprland", extra_env=PKGCONF_ENV) + require(PKGCONF.is_file(), "canonical /usr/bin/pkgconf is required") require(run("pacman", "-Qoq", str(PKGCONF)) == "pkgconf", "pkgconf executable owner mismatch") - require(run(str(PKGCONF), "--variable=pcfiledir", "hyprland") == str(HYPRLAND_PC.parent), "noncanonical Hyprland pkg-config source") + require(query("--variable=pcfiledir") == str(HYPRLAND_PC.parent), "noncanonical Hyprland pkg-config source") require(HYPRLAND_PC.is_file() and not HYPRLAND_PC.is_symlink() and HYPRLAND_PC.resolve() == HYPRLAND_PC, "Hyprland pkg-config source must be canonical") require(run("pacman", "-Qoq", str(HYPRLAND_PC)) == "hyprland", "Hyprland pkg-config owner mismatch") - require(run(str(PKGCONF), "--modversion", "hyprland") == profile["hyprland"]["header_version"], "Hyprland header mismatch") - cflags = shlex.split(run(str(PKGCONF), "--cflags", "hyprland")) - includes = shlex.split(run(str(PKGCONF), "--cflags-only-I", "hyprland")) - other = shlex.split(run(str(PKGCONF), "--cflags-only-other", "hyprland")) + require(query("--modversion") == profile["hyprland"]["header_version"], "Hyprland header mismatch") + cflags = shlex.split(query("--cflags")) + includes = shlex.split(query("--cflags-only-I")) + other = shlex.split(query("--cflags-only-other")) require(all(flag.startswith("-I") and len(flag) > 2 for flag in includes), "unexpected pkg-config include flags") include_dirs = [flag[2:] for flag in includes] + selected = include_dirs + if selected and selected[0] == str(SYSTEM_INCLUDE): + # GCC keeps its built-in /usr/include in system-search order even when + # pkgconf emits -I/usr/include. Admit only this exact leading system root. + selected = selected[1:] + system_src = SYSTEM_INCLUDE / "src" + require(not system_src.exists() and not system_src.is_symlink(), "unreviewed system src header tree") + require(str(SYSTEM_INCLUDE) not in selected, "system include root must appear only as the leading entry") # The source includes . Native Hyprland puts its hashed protocols # directory before the root, then src. Require the whole leading selection # through the root to stay inside that hashed tree, before any external root. - require(str(HEADER_ROOT) in include_dirs, "canonical Hyprland header root is missing") - leading = include_dirs[:include_dirs.index(str(HEADER_ROOT)) + 1] + require(str(HEADER_ROOT) in selected, "canonical Hyprland header root is missing") + leading = selected[:selected.index(str(HEADER_ROOT)) + 1] require(all(Path(name).is_relative_to(HEADER_ROOT) for name in leading), "Hyprland headers are not first in pkg-config include selection") for name in include_dirs: path = Path(name) @@ -318,9 +332,10 @@ def pkgconfig_selection(profile): require([flag for flag in cflags if flag.startswith("-I")] == includes and [flag for flag in cflags if not flag.startswith("-I")] == other, "inconsistent pkg-config flags") verify_flags(other, "pkg-config CFLAGS_OTHER") - libraries = shlex.split(run(str(PKGCONF), "--libs", "hyprland")) + libraries = shlex.split(query("--libs")) return {"executable": str(PKGCONF), "executable_sha256": digest(PKGCONF), "pc_path": str(HYPRLAND_PC), "pc_sha256": digest(HYPRLAND_PC), + "query_environment": dict(PKGCONF_ENV), "cflags": cflags, "include_dirs": include_dirs, "cflags_other": other, "ldflags": libraries} diff --git a/libs/cua-driver/hyprland-plugin/packaging/release/profiles/omarchy-stable-20260910.json b/libs/cua-driver/hyprland-plugin/packaging/release/profiles/omarchy-stable-20260910.json index 8bc2e4f00f..6e214e47b0 100644 --- a/libs/cua-driver/hyprland-plugin/packaging/release/profiles/omarchy-stable-20260910.json +++ b/libs/cua-driver/hyprland-plugin/packaging/release/profiles/omarchy-stable-20260910.json @@ -33,6 +33,7 @@ "hyprutils": "0.14.2-1", "libgcc": "16.2.1+r23+gd564253eb6c8-1", "libstdc++": "16.2.1+r23+gd564253eb6c8-1", + "libxkbcommon": "1.13.2-1", "wayland": "1.26.0-1" } } diff --git a/libs/cua-driver/hyprland-plugin/packaging/release/test_profile_release.py b/libs/cua-driver/hyprland-plugin/packaging/release/test_profile_release.py index dd2ca12ffa..7c8487a200 100644 --- a/libs/cua-driver/hyprland-plugin/packaging/release/test_profile_release.py +++ b/libs/cua-driver/hyprland-plugin/packaging/release/test_profile_release.py @@ -318,8 +318,10 @@ def setUp(self): self.probe_comment = None self.pkgconfig = {"executable": "/usr/bin/pkgconf", "executable_sha256": "e" * 64, "pc_path": "/usr/share/pkgconfig/hyprland.pc", "pc_sha256": "f" * 64, - "cflags": ["-I/usr/include/hyprland", "-pthread"], - "include_dirs": ["/usr/include/hyprland"], "cflags_other": ["-pthread"], "ldflags": ["-lhyprutils"]} + "query_environment": dict(verify.PKGCONF_ENV), + "cflags": ["-I/usr/include", "-I/usr/include/hyprland/protocols", "-I/usr/include/hyprland", "-I/usr/include/hyprland/src"], + "include_dirs": ["/usr/include", "/usr/include/hyprland/protocols", "/usr/include/hyprland", "/usr/include/hyprland/src"], + "cflags_other": [], "ldflags": ["-L/usr/lib", "-lhyprutils"]} def fake_run(self, *args, input=None): self.calls.append(args) @@ -444,6 +446,7 @@ def test_build_configuration_and_shared_runtime_checks(self): def test_header_and_toolchain_environment_overrides_refused(self): for name in ("PKG_CONFIG_PATH", "PKG_CONFIG_LIBDIR", "PKG_CONFIG_SYSROOT_DIR", "PKG_CONFIG", "PKGCONF_PKG_PKGF", + "PKG_CONFIG_ALLOW_SYSTEM_CFLAGS", "PKG_CONFIG_ALLOW_SYSTEM_LIBS", "CPATH", "CPLUS_INCLUDE_PATH", "GCC_EXEC_PREFIX", "COMPILER_PATH", "LIBRARY_PATH", "CMAKE_PREFIX_PATH", "CMAKE_TOOLCHAIN_FILE"): with self.subTest(name=name), mock.patch.dict(verify.os.environ, {name: "/alternate-same-version"}, clear=True), self.assertRaisesRegex(ValueError, "routing environment refused"): @@ -476,9 +479,10 @@ def test_canonical_pkgconf_rejects_alternate_same_version_headers(self): def fake_run(*args, **kwargs): if args[:2] == ("pacman", "-Qoq"): return "pkgconf" if args[2] == str(executable) else "hyprland" + self.assertEqual(kwargs.get("extra_env"), verify.PKGCONF_ENV) return {"--variable=pcfiledir": pc_directory, "--modversion": "0.56.2", "--cflags": include_flags + " -pthread", "--cflags-only-I": include_flags, - "--cflags-only-other": "-pthread", "--libs": "-lhyprutils"}[args[1]] + "--cflags-only-other": "-pthread", "--libs": "-L/usr/lib -lhyprutils"}[args[1]] with mock.patch.object(verify, "PKGCONF", executable), mock.patch.object(verify, "HYPRLAND_PC", pc), \ mock.patch.object(verify, "HEADER_ROOT", headers), mock.patch.object(verify, "SYSTEM_INCLUDE", system), \ @@ -486,11 +490,26 @@ def fake_run(*args, **kwargs): selected = verify.pkgconfig_selection(self.profile) self.assertEqual(selected["pc_sha256"], verify.digest(pc)) self.assertEqual(selected["include_dirs"], [str(headers)]) + self.assertEqual(selected["query_environment"], verify.PKGCONF_ENV) + self.assertEqual(selected["ldflags"], ["-L/usr/lib", "-lhyprutils"]) (headers / "protocols").mkdir() (headers / "src").mkdir() include_flags = f"-I{headers}/protocols -I{headers} -I{headers}/src" self.assertEqual(verify.pkgconfig_selection(self.profile)["include_dirs"], [str(headers / "protocols"), str(headers), str(headers / "src")]) + include_flags = f"-I{system} -I{headers}/protocols -I{headers} -I{headers}/src" + self.assertEqual(verify.pkgconfig_selection(self.profile)["include_dirs"], + [str(system), str(headers / "protocols"), str(headers), str(headers / "src")]) + (system / "src").mkdir() + with self.assertRaisesRegex(ValueError, "unreviewed system src"): + verify.pkgconfig_selection(self.profile) + (system / "src").rmdir() + include_flags = f"-I{headers} -I{system}" + with self.assertRaisesRegex(ValueError, "only as the leading entry"): + verify.pkgconfig_selection(self.profile) + include_flags = f"-I{system} -I{alternate} -I{headers}" + with self.assertRaisesRegex(ValueError, "not first"): + verify.pkgconfig_selection(self.profile) include_flags = f"-I{alternate} -I{headers}" with self.assertRaisesRegex(ValueError, "not first"): verify.pkgconfig_selection(self.profile) @@ -499,6 +518,13 @@ def fake_run(*args, **kwargs): with self.assertRaisesRegex(ValueError, "noncanonical Hyprland pkg-config source"): verify.pkgconfig_selection(self.profile) + def test_pkgconf_fixed_query_environment_does_not_mutate_caller(self): + with mock.patch.dict(verify.os.environ, {"LC_ALL": "caller-locale"}, clear=True), \ + mock.patch.object(verify.subprocess, "check_output", return_value="flags\n") as execute: + self.assertEqual(verify.run("/usr/bin/pkgconf", "--cflags", "hyprland", extra_env=verify.PKGCONF_ENV), "flags") + self.assertEqual(execute.call_args.kwargs["env"], dict(verify.PKGCONF_ENV, LC_ALL="C")) + self.assertEqual(dict(verify.os.environ), {"LC_ALL": "caller-locale"}) + def test_consumer_does_not_invoke_compiler_or_headers_and_refuses_drift(self): self.native_context() self.profile_path.write_bytes(verify.json_bytes(self.profile)) From 545268481af8a056a462418e16449f0d9ffff7a2 Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Wed, 9 Sep 2026 21:36:07 -0500 Subject: [PATCH 05/27] test(cua-driver): observe primary input independently on production packages --- .../hyprland-plugin/tests/primary_observer.py | 293 +++++++++++++++++ .../tests/primary_observer_fixture.py | 216 ++++++++++++ .../tests/production-inkscape-profile.md | 95 +++++- .../tests/production_primary_observer_test.py | 307 ++++++++++++++++++ .../tests/production_realapp_proof.py | 46 ++- 5 files changed, 953 insertions(+), 4 deletions(-) create mode 100644 libs/cua-driver/hyprland-plugin/tests/primary_observer.py create mode 100644 libs/cua-driver/hyprland-plugin/tests/primary_observer_fixture.py create mode 100644 libs/cua-driver/hyprland-plugin/tests/production_primary_observer_test.py diff --git a/libs/cua-driver/hyprland-plugin/tests/primary_observer.py b/libs/cua-driver/hyprland-plugin/tests/primary_observer.py new file mode 100644 index 0000000000..7a135e1113 --- /dev/null +++ b/libs/cua-driver/hyprland-plugin/tests/primary_observer.py @@ -0,0 +1,293 @@ +"""Independent parked-primary client evidence, never compositor attribution.""" +import hashlib +import json +import math +import os +from pathlib import Path +import re +import socket +import stat +import struct +import subprocess +import time +import uuid + +MAX_BYTES = 32 * 1024 * 1024 +MAX_RECORDS = 100000 +MAX_GAP_NS = 1_000_000_000 +MAX_INTERVAL_NS = 60_000_000_000 +WIRE = re.compile(r'^\[\s*(\d+\.\d+)\]\s*(?:\{[^}]+\}\s*)?(?P->\s*)?' + r'(?P\w+)[#@](?P\d+)\.(?P\w+)\((?P.*)\)$') + + +def wire_rows(data): + assert len(data) <= MAX_BYTES and (not data or data.endswith(b'\n')), 'incomplete or oversized wire log' + rows = [] + for line in data.decode('utf-8').splitlines(): + match = WIRE.fullmatch(line) + assert match, 'unparseable Wayland wire record' + rows.append({**match.groupdict(), 'object': int(match['object']), 'out': match['out'] is not None}) + assert len(rows) <= MAX_RECORDS, 'wire event limit exceeded' + return rows + + +def sync_barrier(data): + pending, completed = set(), 0 + for row in wire_rows(data): + if row['out'] and row['interface'] == 'wl_display' and row['event'] == 'sync': + match = re.fullmatch(r'new id wl_callback[#@](\d+)', row['arguments']) + assert match, 'unidentified Wayland sync callback' + callback = int(match[1]) + assert callback not in pending, 'overlapping callback identity' + pending.add(callback) + elif not row['out'] and row['interface'] == 'wl_callback' and row['event'] == 'done': + if row['object'] in pending: + pending.remove(row['object']) + completed += 1 + assert completed == 2 and not pending, 'missing complete independent Wayland sync barriers' + + +def journal_rows(data): + assert data and len(data) <= MAX_BYTES and data.endswith(b'\n'), 'incomplete or oversized journal' + rows = [json.loads(line) for line in data.splitlines()] + assert len(rows) <= MAX_RECORDS, 'journal record limit exceeded' + for index, row in enumerate(rows): + assert type(row['seq']) is int and row['seq'] == index + 1, 'journal sequence gap' + assert type(row['time']) is int and row['time'] > 0 + assert row['instance'] == rows[0]['instance'], 'journal producer changed' + if index: + assert row['time'] >= rows[index - 1]['time'], 'journal clock regressed' + assert rows[0]['kind'] == 'ready' and rows[0]['native_wayland'] is True, 'missing native observer startup' + return rows + + +def pointer_position(row): + values = row['arguments'].split(',') + assert len(values) == 3, 'malformed primary pointer motion' + point = [float(value) for value in values[1:]] + assert all(math.isfinite(value) for value in point), 'nonfinite primary position' + return point + + +def primary_wire_state(data): + pointers, keyboards = {}, {} + for row in wire_rows(data): + if row['out']: + continue + interface, event, obj = row['interface'], row['event'], row['object'] + values = [value.strip() for value in row['arguments'].split(',')] + if interface == 'wl_pointer': + state = pointers.setdefault(obj, {'surface': None, 'buttons': set(), 'position': None}) + if event == 'enter': + assert len(values) == 4 and re.fullmatch(r'wl_surface[#@]\d+', values[1]) + state.update(surface=int(re.split('[#@]', values[1])[1]), + position=[float(value) for value in values[2:]]) + elif event == 'leave': + state['surface'] = None + elif event == 'motion': + state['position'] = pointer_position(row) + elif event == 'button': + assert len(values) == 4 and values[3] in ('0', '1'), 'malformed primary button event' + if values[3] == '1': + state['buttons'].add(int(values[2])) + else: + state['buttons'].discard(int(values[2])) + elif interface == 'wl_keyboard': + state = keyboards.setdefault(obj, {'surface': None, 'keys': set(), 'modifiers': None}) + if event == 'enter': + assert len(values) == 3 and re.fullmatch(r'wl_surface[#@]\d+', values[1]) + assert values[2] == 'array[0]', 'primary keyboard entered with held keys' + state['surface'] = int(re.split('[#@]', values[1])[1]) + elif event == 'leave': + state['surface'] = None + elif event == 'key': + assert len(values) == 4 and values[3] in ('0', '1') + if values[3] == '1': + state['keys'].add(int(values[2])) + else: + state['keys'].discard(int(values[2])) + elif event == 'modifiers': + assert len(values) == 5 + state['modifiers'] = [int(value) for value in values[1:]] + pointers = [(obj, row) for obj, row in pointers.items() if row['surface'] is not None] + keyboards = [(obj, row) for obj, row in keyboards.items() if row['surface'] is not None] + assert len(pointers) == len(keyboards) == 1, 'need one active primary pointer and keyboard' + pointer_id, pointer = pointers[0] + keyboard_id, keyboard = keyboards[0] + assert pointer['surface'] == keyboard['surface'], 'primary pointer/keyboard focus differs' + assert pointer['buttons'] == {272}, 'primary wire does not prove the held left-button grab' + assert not keyboard['keys'] and keyboard['modifiers'] == [0, 0, 0, 0], 'primary keyboard is not idle' + assert pointer['position'] is not None and all(math.isfinite(value) for value in pointer['position']) + return {'pointer': pointer_id, 'keyboard': keyboard_id, 'surface': pointer['surface'], + 'position': pointer['position'], 'held_button': 272} + + +def analyze(before, after, journal, wire, intervals, primary_before, primary_after): + """An incomplete observation raises; a complete observation can fail isolation.""" + assert before['identity'] == after['identity'], 'observer identity changed' + start, end = before['marker'], after['marker'] + assert start['kind'] == end['kind'] == 'sync' and start['nonce'] != end['nonce'] + assert start['time'] < end['time'] and end['time'] - start['time'] <= MAX_INTERVAL_NS + assert journal[start['seq'] - 1] == start and journal[end['seq'] - 1] == end, 'sync marker journal mismatch' + assert end['seq'] == len(journal), 'end marker is not the final retained journal row' + assert 0 <= start['wire_start'] < start['wire_end'] <= end['wire_start'] < end['wire_end'] == len(wire) + sync_barrier(wire[start['wire_start']:start['wire_end']]) + sync_barrier(wire[end['wire_start']:end['wire_end']]) + assert start['held'] is True and start['buttons'] == [1] and not start['keys_down'] + assert start['window_active'] is True and start['canvas_focus'] is True, 'foreground fixture is not focused' + assert intervals and all(start['time'] <= begin < finish <= end['time'] for begin, finish in intervals), \ + 'observer does not cover the complete action/control interval' + rows = journal[start['seq']:] + assert all(row['kind'] != 'sync' for row in rows[:-1]), 'unexpected observer synchronization inside interval' + heartbeats = [start['time']] + [row['time'] for row in rows if row['kind'] == 'state'] + [end['time']] + assert all(0 <= right - left <= MAX_GAP_NS for left, right in zip(heartbeats, heartbeats[1:])), \ + 'observer heartbeat gap or stalled event loop' + primary = primary_wire_state(wire[:start['wire_end']]) + violations = [] + motions = [] + if primary_before != primary_after: + violations.append({'kind': 'primary_endpoints'}) + for row in rows: + if row['kind'] in ('state', 'sync'): + for key in ('clicks', 'keys', 'scroll', 'held', 'buttons', 'keys_down', 'window_active', 'canvas_focus', 'motion'): + if row[key] != start[key]: + violations.append({'kind': 'journal_state', 'field': key, 'seq': row['seq']}) + else: + violations.append({'kind': 'journal_event', 'event': row['kind'], 'seq': row['seq']}) + events = wire_rows(wire[start['wire_end']:end['wire_end']]) + for row in events: + if row['out']: + continue + interface, event = row['interface'], row['event'] + if interface == 'wl_pointer' and event == 'motion': + motions.append({'object': row['object'], 'position': pointer_position(row)}) + forbidden = ((interface == 'wl_pointer' and event != 'frame') or interface == 'wl_keyboard' + or interface == 'zwp_relative_pointer_v1' or interface == 'wl_touch' + or interface == 'wl_seat' or (interface == 'wl_display' and event == 'error')) + if forbidden: + violations.append({'kind': 'wire_event', 'interface': interface, 'event': event, 'object': row['object']}) + return {'result': 'failed' if violations else 'passed', + 'scope': 'independent-parked-primary-client', 'compositor_attribution': False, + 'primary': primary, 'start_ns': start['time'], 'end_ns': end['time'], + 'action_intervals': intervals, 'journal_records': len(rows), 'wire_records': len(events), + 'complete': True, 'violations': violations, 'motions': motions} + + +def verify_negative_control(result): + assert result['result'] == 'failed' and result['complete'] is True, 'control did not fail the normal detector' + baseline = result['primary']['position'] + motions = result['motions'] + assert len(motions) >= 2 and all(row['object'] == result['primary']['pointer'] for row in motions) + assert any(row['position'] != baseline for row in motions) and motions[-1]['position'] == baseline, \ + 'wire control does not prove an excursion and return' + for row in result['violations']: + assert ((row['kind'] == 'wire_event' and row['interface'] == 'wl_pointer' and row['event'] == 'motion') + or (row['kind'] == 'journal_event' and row['event'] == 'motion-notify') + or (row['kind'] == 'journal_state' and row['field'] == 'motion')), 'control failed for an unrelated reason' + return {'verified': True, 'detector_result': 'failed', 'wire_motion_events': len(motions)} + + +class PrimaryObserver: + def __init__(self, control, foreground, journal, evidence): + self.control = control.resolve(strict=True) + self.foreground = foreground + self.journal = journal.resolve(strict=True) + self.evidence = evidence + self.source = Path(__file__).with_name('primary_observer_fixture.py').resolve(strict=True) + self.before = None + self.prefixes = {} + self.descriptors = {} + self.process_start = self._process_identity() + + def _process_identity(self): + pid = self.foreground['pid'] + process = Path('/proc') / str(pid) + argv = (process / 'cmdline').read_bytes().split(b'\0') + assert str(self.source).encode() in argv and str(self.journal).encode() in argv, 'foreground is not the reviewed observer fixture' + clients = json.loads(subprocess.check_output(['hyprctl', '-j', 'clients'], text=True, timeout=5)) + matches = [row for row in clients if row.get('pid') == pid] + assert len(matches) == 1 and matches[0].get('xwayland') is False + assert int(matches[0]['address'], 16) == self.foreground['window_id'], 'observer native target changed' + return (process / 'stat').read_text().rsplit(')', 1)[1].split()[19] + + def _sync(self): + assert self._process_identity() == self.process_start, 'observer process changed' + info = self.control.lstat() + assert stat.S_ISSOCK(info.st_mode) and info.st_uid == os.getuid(), 'invalid observer control socket' + nonce = uuid.uuid4().hex + requested = time.monotonic_ns() + with socket.socket(socket.AF_UNIX, socket.SOCK_SEQPACKET) as client: + client.settimeout(2) + client.connect(str(self.control)) + pid, uid, _ = struct.unpack('3i', client.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12)) + assert pid == self.foreground['pid'] and uid == os.getuid(), 'observer socket peer mismatch' + client.sendall(json.dumps({'command': 'SYNC', 'nonce': nonce}).encode()) + data, _, flags, _ = client.recvmsg(16384) + assert data and not flags & socket.MSG_TRUNC, 'incomplete observer acknowledgement' + received = time.monotonic_ns() + packet = json.loads(data) + marker, identity = packet['marker'], packet['identity'] + assert re.fullmatch(r'[0-9a-f]{32}', identity['instance']) and marker['instance'] == identity['instance'], \ + 'observer instance identity mismatch' + assert marker['nonce'] == nonce and requested <= marker['time'] <= received, 'stale observer synchronization' + assert received - requested <= 2_000_000_000, 'observer synchronization exceeded deadline' + assert identity['pid'] == pid and identity['uid'] == uid and identity['native_wayland'] is True + assert identity['source_sha256'] == hashlib.sha256(self.source.read_bytes()).hexdigest(), 'observer source identity mismatch' + packet['controller_interval'] = [requested, received] + return packet + + def _read(self, name, packet, size): + metadata = packet['identity'][name] + path = Path(metadata['path']) + assert path.resolve(strict=True) == path, 'noncanonical observer log' + if name == 'journal': + assert path == self.journal, 'foreground journal identity mismatch' + if name not in self.descriptors: + self.descriptors[name] = os.open(path, os.O_RDONLY | os.O_NOFOLLOW) + descriptor = self.descriptors[name] + current, opened = path.lstat(), os.fstat(descriptor) + assert stat.S_ISREG(current.st_mode) and current.st_uid == os.getuid() + assert (current.st_dev, current.st_ino) == (opened.st_dev, opened.st_ino) == (metadata['device'], metadata['inode']), \ + 'observer log replaced' + assert type(size) is int and 0 < size <= current.st_size <= MAX_BYTES, 'observer log truncated or overflowed' + data = os.pread(descriptor, size, 0) + assert len(data) == size, 'short observer log read' + if name in self.prefixes: + assert data.startswith(self.prefixes[name]), 'observer history changed' + return data + + def start(self, primary): + self.before = self._sync() + self._save('primary-observer-begin.json', self.before) + self.primary = primary + self.prefixes['journal'] = self._read('journal', self.before, self.before['journal_end']) + self.prefixes['wire'] = self._read('wire', self.before, self.before['marker']['wire_end']) + (self.evidence / 'primary-observer-begin-journal.jsonl').write_bytes(self.prefixes['journal']) + (self.evidence / 'primary-observer-begin-wire.log').write_bytes(self.prefixes['wire']) + rows = journal_rows(self.prefixes['journal']) + assert all(rows[0][key] == value for key, value in self.before['identity'].items()), 'observer startup identity mismatch' + assert rows[-1] == self.before['marker'], 'baseline marker is not the retained journal tail' + marker = self.before['marker'] + sync_barrier(self.prefixes['wire'][marker['wire_start']:marker['wire_end']]) + primary_wire_state(self.prefixes['wire']) + assert marker['held'] is True and marker['buttons'] == [1] and not marker['keys_down'] + assert marker['window_active'] is True and marker['canvas_focus'] is True + + def _save(self, name, value): + (self.evidence / name).write_text(json.dumps(value, indent=2) + '\n') + + def finish(self, intervals, primary): + after = self._sync() + self._save('primary-observer-end.json', after) + journal = self._read('journal', after, after['journal_end']) + wire = self._read('wire', after, after['marker']['wire_end']) + (self.evidence / 'primary-observer-journal.jsonl').write_bytes(journal) + (self.evidence / 'primary-observer-wire.log').write_bytes(wire) + result = analyze(self.before, after, journal_rows(journal), wire, intervals, self.primary, primary) + self._save('primary-observer-isolation.json', result) + return result + + def close(self): + for descriptor in self.descriptors.values(): + os.close(descriptor) + self.descriptors.clear() diff --git a/libs/cua-driver/hyprland-plugin/tests/primary_observer_fixture.py b/libs/cua-driver/hyprland-plugin/tests/primary_observer_fixture.py new file mode 100644 index 0000000000..eaa996cccd --- /dev/null +++ b/libs/cua-driver/hyprland-plugin/tests/primary_observer_fixture.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 +"""Test-only GTK foreground observer with independent, synchronized wire evidence. + +Requires PyGObject GTK3 and pycairo on native Wayland. No plugin trace is used. +All paths must be fresh. The controller only requests synchronization, never +input. WAYLAND_DEBUG is enabled before GTK connects and retained verbatim. +""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import socket +import stat +import struct +import time +import uuid + +MAX_BYTES = 32 * 1024 * 1024 +MAX_RECORDS = 100000 + + +def main(): + if not __debug__: + raise RuntimeError('assertions must be enabled') + parser = argparse.ArgumentParser(description=__doc__) + for name in ('journal', 'wire', 'control'): + parser.add_argument('--' + name, required=True, type=Path) + parser.add_argument('--lifetime-ms', type=int, default=180000) + args = parser.parse_args() + assert 5000 <= args.lifetime_ms <= 600000 + paths = [path.absolute() for path in (args.journal, args.wire, args.control)] + assert len(set(paths)) == 3 and all(path.parent.resolve() == path.parent for path in paths) + journal_path, wire_path, control_path = paths + os.environ['GDK_BACKEND'] = 'wayland' + os.environ['WAYLAND_DEBUG'] = 'client' + journal = journal_path.open('xb', buffering=0) + wire_fd = os.open(wire_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + os.dup2(wire_fd, 2) + os.close(wire_fd) + import gi + gi.require_version('Gtk', '3.0') + gi.require_version('Gdk', '3.0') + from gi.repository import Gdk, GLib, GObject, Gtk + + display = Gdk.Display.get_default() + assert display and GObject.type_name(display.__gtype__) == 'GdkWaylandDisplay' + controller = socket.socket(socket.AF_UNIX, socket.SOCK_SEQPACKET) + controller.bind(str(control_path)) + os.chmod(control_path, 0o600) + controller.listen(1) + controller.setblocking(False) + identity = {'pid': os.getpid(), 'uid': os.getuid(), 'instance': uuid.uuid4().hex, + 'source_sha256': hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), + 'native_wayland': True} + for name, path in (('journal', journal_path), ('wire', wire_path)): + info = path.stat() + assert stat.S_ISREG(info.st_mode) + identity[name] = {'path': str(path), 'device': info.st_dev, 'inode': info.st_ino} + counters = {'clicks': 0, 'keys': '', 'scroll': 0, 'motion': 0, 'held': False} + buttons, keys, nonces = set(), set(), set() + sequence, failed = 0, False + window = Gtk.Window(title='Cua Isolated Input Foreground') + window.set_default_size(600, 500) + canvas = Gtk.DrawingArea() + canvas.set_can_focus(True) + canvas.add_events(Gdk.EventMask.ALL_EVENTS_MASK) + window.add(canvas) + + def checked_sizes(): + assert sequence < MAX_RECORDS, 'observer record limit reached' + assert os.fstat(journal.fileno()).st_size < MAX_BYTES and os.fstat(2).st_size < MAX_BYTES, \ + 'observer byte limit reached' + + def record(kind, **values): + nonlocal sequence + checked_sizes() + sequence += 1 + row = {'kind': kind, 'time': time.monotonic_ns(), 'seq': sequence, + 'instance': identity['instance'], **values} + encoded = (json.dumps(row, separators=(',', ':')) + '\n').encode() + written = journal.write(encoded) + assert written == len(encoded), 'short observer journal write' + return row + + def current(): + return {**counters, 'buttons': sorted(buttons), 'keys_down': sorted(keys), + 'window_active': bool(window.is_active()), 'canvas_focus': bool(canvas.has_focus())} + + def abort(error): + nonlocal failed + failed = True + # A missing final sync or any non-protocol wire line fails the reader. + print('primary observer failed: ' + str(error), flush=True) + Gtk.main_quit() + + def draw(widget, cr): + cr.set_source_rgb(0.08, 0.13, 0.18) + cr.paint() + cr.set_source_rgb(0.75, 0.9, 0.75) + cr.select_font_face('sans-serif', 0, 0) + cr.set_font_size(24) + for index, line in enumerate(('Foreground — independent primary observer', + 'held=' + str(counters['held']), + 'motion=' + str(counters['motion']))): + cr.move_to(20, 55 + index * 45) + cr.show_text(line) + return False + + def event(widget, e): + try: + data = {} + if e.type in (Gdk.EventType.BUTTON_PRESS, Gdk.EventType.BUTTON_RELEASE, + Gdk.EventType.MOTION_NOTIFY, Gdk.EventType.SCROLL): + data.update(x=e.x, y=e.y) + if e.type in (Gdk.EventType.BUTTON_PRESS, Gdk.EventType.BUTTON_RELEASE): + _, button = e.get_button() + data['button'] = int(button) + if e.type == Gdk.EventType.BUTTON_PRESS: + buttons.add(int(button)) + else: + buttons.discard(int(button)) + counters['clicks'] += 1 + counters['held'] = 1 in buttons + canvas.grab_focus() + elif e.type in (Gdk.EventType.KEY_PRESS, Gdk.EventType.KEY_RELEASE): + key = Gdk.keyval_name(e.keyval) or 'unknown' + data.update(key=key, modifiers=int(e.state)) + if e.type == Gdk.EventType.KEY_PRESS: + keys.add(int(e.hardware_keycode)) + counters['keys'] += key + else: + keys.discard(int(e.hardware_keycode)) + elif e.type == Gdk.EventType.MOTION_NOTIFY: + counters['motion'] += 1 + elif e.type == Gdk.EventType.SCROLL: + counters['scroll'] += 1 + if e.type not in (Gdk.EventType.EXPOSE, Gdk.EventType.CONFIGURE): + record(e.type.value_nick, **data) + canvas.queue_draw() + except Exception as error: + abort(error) + return False + + def heartbeat(): + try: + record('state', **current()) + return True + except Exception as error: + abort(error) + return False + + def drain(): + deadline = time.monotonic() + 1 + iterations = 0 + while Gtk.events_pending(): + assert time.monotonic() < deadline and iterations < 1024, 'GTK event drain did not complete' + Gtk.main_iteration_do(False) + iterations += 1 + + def request(channel, condition): + try: + peer, _ = controller.accept() + except BlockingIOError: + return True + with peer: + peer.settimeout(1) + try: + credentials = peer.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12) + _, uid, _ = struct.unpack('3i', credentials) + assert uid == os.getuid(), 'foreign controller' + packet, _, flags, _ = peer.recvmsg(1024) + assert not flags & socket.MSG_TRUNC, 'truncated sync request' + command = json.loads(packet) + nonce = command['nonce'] + assert set(command) == {'command', 'nonce'} and command['command'] == 'SYNC' + assert isinstance(nonce, str) and len(nonce) == 32 and all(c in '0123456789abcdef' for c in nonce) + assert nonce not in nonces and len(nonces) < 32, 'replayed or excessive synchronization' + nonces.add(nonce) + checked_sizes() + wire_start = os.fstat(2).st_size + # Two explicit roundtrips, with queued GTK delivery drained. + # The controller validates the matching callback.done records. + for _ in range(2): + display.sync() + drain() + checked_sizes() + wire_end = os.fstat(2).st_size + marker = record('sync', nonce=nonce, wire_start=wire_start, + wire_end=wire_end, **current()) + response = {'identity': identity, 'marker': marker, 'journal_end': journal.tell()} + peer.sendall(json.dumps(response).encode()) + except Exception as error: + abort(error) + return not failed + + canvas.connect('draw', draw) + canvas.connect('event', event) + window.connect('destroy', Gtk.main_quit) + window.show_all() + canvas.grab_focus() + record('ready', **identity) + GLib.timeout_add(100, heartbeat) + GLib.io_add_watch(controller.fileno(), GLib.IO_IN, request) + GLib.timeout_add(args.lifetime_ms, lambda: (abort('bounded observer lifetime expired'), False)[1]) + print('PRIMARY_OBSERVER_READY', flush=True) + try: + Gtk.main() + finally: + controller.close() + journal.close() + return 1 if failed else 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/libs/cua-driver/hyprland-plugin/tests/production-inkscape-profile.md b/libs/cua-driver/hyprland-plugin/tests/production-inkscape-profile.md index ed2ae9bb5f..56d265551a 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production-inkscape-profile.md +++ b/libs/cua-driver/hyprland-plugin/tests/production-inkscape-profile.md @@ -82,8 +82,9 @@ This binding complements the packaging verifier and package integrity checks; it is not a package signature, compiler/runtime compatibility check, or native certification. Diagnostic attribution cannot certify trace-disabled bytes. Production runs retain the existing `production-package-smoke` scope and leave -continuous trace isolation unproven. Separate fresh-session package lifecycle -and independent primary-input observations remain required for shipping. +continuous trace isolation unproven. Explicit production `production_realapp_proof.py` +runs now require the independent primary observer below. Separate fresh-session +package lifecycle evidence remains required for shipping. `production_app_smoke.py --app-profile inkscape-only` runs the existing bounded single-app keyboard smoke with only the Inkscape package gate and SVG fixture. @@ -92,6 +93,96 @@ concurrency, complete isolation, or full desktop certification claim. Fault/cancellation helper plans may also select `app_profile`; retain their existing required scenario fields and supply `document` on native app agents. +## Independent primary gate for trace-disabled bytes + +`primary_observer_fixture.py` is a test-only replacement for the generic +foreground GTK fixture in this explicit gate. It needs native GTK3 PyGObject +and pycairo; no compilation is needed. It creates no synthetic input. Start it +from the clean harness checkout in the disposable native Wayland session, +using fresh paths (the journal, wire file, and control socket must not exist): + +```sh +cd libs/cua-driver/hyprland-plugin/tests +observer_dir=$(mktemp -d) +python3 "$(pwd)/primary_observer_fixture.py" \ + --journal "$observer_dir/foreground.jsonl" \ + --wire "$observer_dir/foreground.wire" \ + --control "$observer_dir/control.sock" \ + --lifetime-ms 600000 >"$observer_dir/fixture.stdout" & +``` + +The fixture owns its `WAYLAND_DEBUG=client` capture, enforces the Wayland +backend, and maps `Cua Isolated Input Foreground`. Bind the plan's exact +foreground PID and native window ID to this process using fresh snapshots. +Use the existing independently built `primary_grab` helper to hold its left +button, as the real-app runner normally does. Add these arguments to the +production-role real-app proof with its existing source, package manifest, +app-plan, primary-grab, and evidence arguments: + +```sh +--foreground-journal "$observer_dir/foreground.jsonl" \ +--primary-observer "$observer_dir/control.sock" +``` + +This gate is explicitly parked-primary: `purpose: "apps"` and +`purpose: "negative_control"` are supported. The normal app plan still requires +two independent runtimes, two exact native app targets, and both saved SVG +oracles (except the existing intermediate pointer episodes). All Driver input +uses the ordinary fresh-snapshot route. Moving-primary, compositor lane +attribution, actual compositor overlap, and no-dispatch capacity remain the +existing diagnostic trace gates. A pair of overlapping tool calls is not +proof of overlapping compositor delivery; `require_overlap` cannot pass on +this no-trace path. + +Before the first action and after all actions and agent-runtime cleanup, the +reader sends a fresh nonce over the fixture's local control socket. It verifies +the socket's kernel peer PID/UID, exact native foreground window, process +start identity, and fixture source digest. Each acknowledgement follows two +`Gdk.Display.sync()` roundtrips with a bounded GTK event drain. The reader also +requires their matching Wayland `sync`/`callback.done` wire records, so a +heartbeat or an old file alone cannot satisfy a boundary. + +The retained interval must enclose every action's request/response interval. +It ends before the primary button is released. Log files must retain the same +device/inode and unchanged prefixes, complete newline-delimited records, +unbroken journal sequence and monotonic timestamps, matching producer identity, +and fresh state heartbeats with no gap over one second. The fixture and reader +enforce 32 MiB/100,000-record limits, two-second sync deadlines, and an action +interval of at most 60 seconds. A deadline, missing acknowledgement, truncated +record, changed file, stalled event loop, unknown wire record, or exceeded limit +fails qualification. Raw begin/end journal and wire evidence are retained. + +Baseline wire evidence must show one primary pointer and keyboard on the same +surface, a held left button, and no held keyboard keys/modifiers. During the +parked interval, any pointer motion (including return to the original position), +enter/leave, button, axis, key, keyboard-focus, seat-capability, or corresponding +journal focus/grab/input transition fails. The foreground client counters and +held-button state must remain unchanged, and the independent compositor +cursor/focus/workspace endpoint checks must also match. This establishes +client-observed primary continuity under the implicit held-button grab; it +does not expose hidden compositor grab state or provide plugin transport +attribution. The existing `continuous_isolation` and `synthetic_cleanup` +trace claims remain `unproven` on this path. The separate +`independent_primary_isolation` result names its narrower scope. + +Run a separate `purpose: "negative_control"` plan with +`"phases": [{"negative_control": true}]`, the same production artifact role, +and the same observer interface. Keep the primary point at least 40 pixels +from the right edge and 30 from the bottom of the fixture. The existing +`primary_grab ... canary` sends an excursion and return in one roundtrip. +The same normal detector must return `failed`, even when GTK coalesces motion +and final cursor positions match. Control acceptance additionally requires +wire motion away and back on the same primary pointer, with only motion-related +violations. A detector that reports `passed`, never observes the excursion, +or fails for another reason cannot pass the control. The enclosing successful +control run reports `production-package-primary-control`; its isolation result +deliberately remains `failed`. + +These interfaces and portable tests record no new native certification. +Native normal/control runs must still be performed on the exact mapped +trace-disabled package. Use fresh sessions for diagnostic versus production +modules and preserve their separate artifact identities. + Portable regression command (Python 3.10 or newer): ```sh diff --git a/libs/cua-driver/hyprland-plugin/tests/production_primary_observer_test.py b/libs/cua-driver/hyprland-plugin/tests/production_primary_observer_test.py new file mode 100644 index 0000000000..9505f76624 --- /dev/null +++ b/libs/cua-driver/hyprland-plugin/tests/production_primary_observer_test.py @@ -0,0 +1,307 @@ +"""Portable independent-observer contracts; no native qualification is claimed.""" +from contextlib import ExitStack +import hashlib +import json +import os +from pathlib import Path +import socket +import stat +import struct +import subprocess +import sys +import tempfile +import time +import unittest +from unittest.mock import Mock, patch +from types import SimpleNamespace + +from primary_observer import (MAX_BYTES, PrimaryObserver, analyze, journal_rows, + primary_wire_state, sync_barrier, verify_negative_control, wire_rows) +from production_realapp_proof import run +from production_realapp_proof_test import plan + + +def wire(*lines): + return ''.join('[12345.000] {Default Queue} ' + line + '\n' for line in lines).encode() + + +SYNC = wire(' -> wl_display#1.sync(new id wl_callback#20)', 'wl_callback#20.done(1)', + ' -> wl_display#1.sync(new id wl_callback#21)', 'wl_callback#21.done(2)') +BASE = wire('wl_pointer#5.enter(1, wl_surface#10, 300.00000000, 300.00000000)', + 'wl_keyboard#6.enter(2, wl_surface#10, array[0])', + 'wl_keyboard#6.modifiers(3, 0, 0, 0, 0)', 'wl_pointer#5.button(4, 100, 272, 1)') +CANARY = wire('wl_pointer#5.motion(101, 340.00000000, 330.00000000)', + 'wl_pointer#5.motion(102, 300.00000000, 300.00000000)') + + +def observation(events=b''): + state = {'clicks': 0, 'keys': '', 'scroll': 0, 'motion': 0, 'held': True, + 'buttons': [1], 'keys_down': [], 'window_active': True, 'canvas_focus': True} + def row(seq, kind, when, **values): + return {'seq': seq, 'instance': 'fixture', 'kind': kind, 'time': when, **values} + data = BASE + SYNC + events + SYNC + begin = row(2, 'sync', 2_000_000_000, **state, nonce='a' * 32, + wire_start=len(BASE), wire_end=len(BASE + SYNC)) + end = row(4, 'sync', 2_200_000_000, **state, nonce='b' * 32, + wire_start=len(BASE + SYNC + events), wire_end=len(data)) + rows = [row(1, 'ready', 1_900_000_000, native_wayland=True), begin, + row(3, 'state', 2_100_000_000, **state), end] + before = {'identity': {'pid': 10}, 'marker': begin} + after = {'identity': {'pid': 10}, 'marker': end} + return before, after, rows, data, [(2_020_000_000, 2_080_000_000)], {'cursor': [300, 300]}, {'cursor': [300, 300]} + + +class ObserverAnalysisTests(unittest.TestCase): + def test_normal_interval_passes_without_claiming_compositor_attribution(self): + result = analyze(*observation()) + self.assertEqual(result['result'], 'passed') + self.assertEqual(result['primary']['position'], [300, 300]) + self.assertEqual(result['primary']['held_button'], 272) + self.assertFalse(result['compositor_attribution']) + self.assertTrue(result['complete']) + + def test_warp_and_return_fails_identical_detector_even_if_gtk_coalesces_it(self): + values = observation(CANARY) + # Journal and endpoint counters intentionally remain identical. + result = analyze(*values) + self.assertEqual(result['result'], 'failed') + self.assertTrue(verify_negative_control(result)['verified']) + self.assertEqual(result['motions'][-1]['position'], result['primary']['position']) + with self.assertRaisesRegex(AssertionError, 'did not fail'): + verify_negative_control(analyze(*observation())) + + def test_focus_grab_keys_buttons_axis_and_any_motion_are_not_endpoint_evidence(self): + events = ( + 'wl_pointer#5.axis(101, 0, 10.0)', 'wl_pointer#5.axis_discrete(0, 1)', + 'wl_pointer#5.axis_value120(0, 120)', 'wl_pointer#5.axis_source(0)', + 'wl_pointer#5.button(5, 101, 272, 0)', 'wl_pointer#5.leave(5, wl_surface#10)', + 'wl_pointer#5.enter(5, wl_surface#10, 300.0, 300.0)', + 'wl_keyboard#6.key(5, 101, 30, 1)', 'wl_keyboard#6.leave(5, wl_surface#10)', + 'wl_keyboard#6.modifiers(5, 1, 0, 0, 0)', 'wl_seat#4.capabilities(0)', + 'zwp_relative_pointer_v1#8.relative_motion(0, 100, 2.0, 0.0, 2.0, 0.0)', + ) + for event in events: + with self.subTest(event=event): + result = analyze(*observation(wire(event))) + self.assertEqual(result['result'], 'failed') + with self.assertRaises(AssertionError): + verify_negative_control(result) + for kind in ('focus-change', 'grab-broken', 'leave-notify', 'button-release', 'key-release', 'scroll'): + values = observation() + values[2][2]['kind'] = kind + self.assertEqual(analyze(*values)['result'], 'failed') + + def test_control_must_return_on_same_pointer_and_fail_only_for_motion(self): + for events in (wire('wl_pointer#5.motion(101, 340.0, 330.0)'), + CANARY + wire('wl_keyboard#6.key(5, 101, 30, 1)'), + CANARY.replace(b'wl_pointer#5', b'wl_pointer#7')): + with self.assertRaises(AssertionError): + verify_negative_control(analyze(*observation(events))) + values = observation(CANARY) + values[-1]['cursor'] = [301, 300] + with self.assertRaisesRegex(AssertionError, 'unrelated'): + verify_negative_control(analyze(*values)) + + def test_full_interval_fresh_heartbeats_and_sync_callbacks_are_mandatory(self): + for mutation in ( + lambda v: v[1]['identity'].update(pid=11), + lambda v: v[1]['marker'].update(nonce='a' * 32), + lambda v: v[1]['marker'].update(time=4_000_000_000), + lambda v: v[4].append((1_999_999_999, 2_100_000_000)), + lambda v: v[4].append((2_100_000_000, 2_300_000_000)), + lambda v: v[4].clear(), + lambda v: v[0]['marker'].update(held=False), + lambda v: v[0]['marker'].update(canvas_focus=False), + lambda v: v[0]['marker'].update(wire_end=len(BASE)), + ): + values = observation() + mutation(values) + with self.assertRaises(AssertionError): + analyze(*values) + for data in (b'', SYNC.replace(b'wl_callback#21.done(2)\n', b''), + SYNC.replace(b'wl_callback#21.done', b'wl_callback#22.done')): + with self.assertRaises(AssertionError): + sync_barrier(data) + + def test_baseline_requires_one_native_surface_with_proven_pointer_grab_and_idle_keyboard(self): + for data in (BASE.replace(b'272, 1', b'272, 0'), + BASE.replace(b'array[0]', b'array[4]'), + BASE.replace(b'modifiers(3, 0, 0, 0, 0)', b'modifiers(3, 1, 0, 0, 0)'), + BASE.replace(b'wl_keyboard#6.enter(2, wl_surface#10', b'wl_keyboard#6.enter(2, wl_surface#11'), + BASE + wire('wl_pointer#7.enter(8, wl_surface#10, 1.0, 1.0)')): + with self.assertRaises(AssertionError): + primary_wire_state(data) + + def test_raw_evidence_cannot_be_partial_malformed_reordered_or_from_another_producer(self): + rows = observation()[2] + data = b''.join((json.dumps(row) + '\n').encode() for row in rows) + self.assertEqual(journal_rows(data), rows) + for bad in (data[:-1], data.replace(b'"seq": 3', b'"seq": 4'), + data.replace(b'2100000000', b'1000000000'), + data.replace(b'"instance": "fixture"', b'"instance": "other"', 1)): + with self.assertRaises(AssertionError): + journal_rows(bad) + for bad in (CANARY[:-1], CANARY + b'logger dropped events\n', b'x' * (MAX_BYTES + 1)): + with self.assertRaises(AssertionError): + wire_rows(bad) + + +class ObserverFileTests(unittest.TestCase): + def test_fixture_help_is_portable_and_optimized_execution_is_refused(self): + fixture = Path(__file__).with_name('primary_observer_fixture.py') + help_text = subprocess.check_output([sys.executable, str(fixture), '--help'], text=True, timeout=5) + self.assertIn('--control', help_text) + self.assertIn('--wire', help_text) + optimized = subprocess.run([sys.executable, '-O', str(fixture), '--help'], text=True, + capture_output=True, timeout=5) + self.assertNotEqual(optimized.returncode, 0) + self.assertIn('assertions must be enabled', optimized.stderr) + + def test_sync_nonce_peer_source_and_freshness_are_all_bound(self): + with tempfile.TemporaryDirectory() as temporary: + source = Path(temporary) / 'fixture.py' + source.write_text('synthetic fixture') + for fault in (None, 'peer', 'uid', 'nonce', 'stale', 'future', 'source', 'truncated', 'process'): + with self.subTest(fault=fault): + observer = object.__new__(PrimaryObserver) + observer.control = Mock(lstat=Mock(return_value=SimpleNamespace(st_mode=stat.S_IFSOCK, st_uid=os.getuid()))) + observer.foreground, observer.source = {'pid': 10}, source + observer.process_start = 'original' + observer._process_identity = Mock(return_value='replacement' if fault == 'process' else 'original') + client = Mock() + client.__enter__ = Mock(return_value=client) + client.__exit__ = Mock(return_value=False) + client.getsockopt.return_value = struct.pack('3i', 11 if fault == 'peer' else 10, + os.getuid() + (fault == 'uid'), 0) + def response(size): + nonce = json.loads(client.sendall.call_args.args[0])['nonce'] + packet = {'identity': {'pid': 10, 'uid': os.getuid(), 'native_wayland': True, + 'instance': 'f' * 32, + 'source_sha256': '0' * 64 if fault == 'source' else hashlib.sha256(source.read_bytes()).hexdigest()}, + 'marker': {'instance': 'f' * 32, 'nonce': 'x' * 32 if fault == 'nonce' else nonce, + 'time': 999 if fault == 'stale' else 3000 if fault == 'future' else 1500}} + return json.dumps(packet).encode(), [], socket.MSG_TRUNC if fault == 'truncated' else 0, None + client.recvmsg.side_effect = response + with patch('primary_observer.socket.socket', return_value=client), \ + patch('primary_observer.socket.SO_PEERCRED', 17, create=True), \ + patch('primary_observer.time.monotonic_ns', side_effect=[1000, 2000]): + if fault is None: + self.assertEqual(observer._sync()['controller_interval'], [1000, 2000]) + else: + with self.assertRaises(AssertionError): + observer._sync() + + def test_inode_prefix_size_and_recording_limits_fail_closed(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary).resolve() + path = root / 'journal' + path.write_bytes(b'old\n') + info = path.stat() + packet = {'identity': {'journal': {'path': str(path), 'device': info.st_dev, 'inode': info.st_ino}}} + observer = object.__new__(PrimaryObserver) + observer.journal, observer.descriptors, observer.prefixes = path, {}, {} + try: + self.assertEqual(observer._read('journal', packet, 4), b'old\n') + observer.prefixes['journal'] = b'old\n' + path.write_bytes(b'new\n') + with self.assertRaisesRegex(AssertionError, 'history changed'): + observer._read('journal', packet, 4) + path.write_bytes(b'o') + with self.assertRaisesRegex(AssertionError, 'truncated'): + observer._read('journal', packet, 4) + path.rename(root / 'original') + path.write_bytes(b'old\n') + with self.assertRaisesRegex(AssertionError, 'replaced'): + observer._read('journal', packet, 4) + finally: + observer.close() + + +class ObserverGateTests(unittest.TestCase): + def test_production_app_and_control_intervals_close_before_primary_release(self): + for purpose, detection in (('apps', 'passed'), ('apps', 'failed'), + ('negative_control', 'failed'), ('negative_control', 'passed')): + with self.subTest(purpose=purpose, detection=detection), tempfile.TemporaryDirectory() as temporary, ExitStack() as stack: + root = Path(temporary) + candidate = plan() + candidate['purpose'] = purpose + for i, spec in enumerate(candidate['agents']): + spec.update(name=f'agent-{i}', profile={'mode': 'standard'}, + bounds={'x': 0, 'y': 0, 'width': 600, 'height': 600}) + candidate['phases'] = ([{'negative_control': True}] if purpose == 'negative_control' else + [{'agent': i, 'tool': 'press_key', 'arguments': {'key': 'a'}} for i in range(2)]) + for i, oracle in enumerate(candidate['outputs']): + output = root / f'output-{i}' + output.write_bytes(b'baseline') + oracle['path'] = str(output) + path = root / 'plan.json' + path.write_text(json.dumps(candidate)) + args = SimpleNamespace(plan=path, evidence=root / 'evidence', artifact_role='production', trace_socket=None, + primary_observer=root / 'control.sock', foreground_journal=root / 'journal', + primary_grab=root / 'primary-grab', driver=root / 'driver', record_video=False) + agents = [Mock(process=Mock(pid=101 + i, poll=Mock(return_value=None))) for i in range(2)] + recorder = Mock() + def tool(name, arguments): + if name == 'get_window_state': + return {'structuredContent': {'window_bounds': candidate['agents'][0]['bounds'], 'screenshot_width': 600}} + if name == 'get_desktop_state': + return {'structuredContent': {'screen_width': 800, 'screen_height': 800}} + if name == 'press_key': + return {'structuredContent': {'route': 'synthetic_events', 'effect': 'unverifiable', + 'delivery': {'mode': 'background'}}} + return {'structuredContent': {}} + for mcp in agents + [recorder]: + mcp.tool.side_effect = tool + held, began = [True], [] + grab = Mock(poll=Mock(return_value=None)) + observer = Mock() + observer.start.side_effect = lambda primary: began.append(time.monotonic_ns()) + result = analyze(*observation(CANARY if detection == 'failed' else b'')) + def finish(intervals, primary): + self.assertTrue(all(agent.close.called for agent in agents)) + self.assertFalse(grab.terminate.called) + self.assertEqual(len(intervals), 2 if purpose == 'apps' else 1) + self.assertTrue(all(began[0] <= first < last <= time.monotonic_ns() for first, last in intervals)) + return result + observer.finish.side_effect = finish + replacements = {'provenance': Mock(return_value={}), 'PrimaryObserver': Mock(return_value=observer), + 'DirectMCP': Mock(side_effect=agents + [recorder]), 'subprocess.Popen': Mock(return_value=grab), + 'subprocess.run': Mock(), 'primary_acknowledgement': Mock(return_value='HELD\n'), + 'verify_output': Mock(return_value={'verified': True}), + 'wait_for': lambda predicate: self.assertTrue(predicate()), + 'state': lambda path: {'held': held[0], 'clicks': 0, 'keys': 0, 'scroll': 0}, + 'wm': lambda: {'pid': 10, 'address': '0x64', 'workspace': 1, 'cursor': {'x': 100, 'y': 200}}, + 'stop_process': lambda process: held.__setitem__(0, False)} + for name, replacement in replacements.items(): + stack.enter_context(patch('production_realapp_proof.' + name, replacement)) + should_pass = (purpose == 'apps') == (detection == 'passed') + self.assertEqual(run(args), 0 if should_pass else 1) + observer.start.assert_called_once() + observer.finish.assert_called_once() + observer.close.assert_called_once() + report = json.loads((args.evidence / 'result.json').read_text()) + self.assertEqual(report['continuous_isolation'], 'unproven') + self.assertEqual(report['synthetic_cleanup'], 'unproven') + self.assertEqual(report['independent_primary_isolation']['result'], detection) + if purpose == 'negative_control' and should_pass: + self.assertTrue(report['negative_control_detected']) + self.assertEqual(report['scope'], 'production-package-primary-control') + self.assertFalse(held[0]) + + def test_explicit_production_requires_observer_before_any_driver_process(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + path = root / 'plan.json' + path.write_text(json.dumps(plan())) + args = SimpleNamespace(plan=path, evidence=root / 'evidence', artifact_role='production', trace_socket=None) + with patch('production_realapp_proof.provenance') as origin, patch('production_realapp_proof.DirectMCP') as spawn: + self.assertEqual(run(args), 1) + origin.assert_not_called() + spawn.assert_not_called() + result = json.loads((args.evidence / 'result.json').read_text()) + self.assertIn('requires the independent primary observer', result['error']) + self.assertEqual(result['continuous_isolation'], 'unproven') + + +if __name__ == '__main__': + unittest.main() diff --git a/libs/cua-driver/hyprland-plugin/tests/production_realapp_proof.py b/libs/cua-driver/hyprland-plugin/tests/production_realapp_proof.py index af194855ec..56130f62e9 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production_realapp_proof.py +++ b/libs/cua-driver/hyprland-plugin/tests/production_realapp_proof.py @@ -21,6 +21,7 @@ from driver_input_live import state, wait_for, wm from primary_trace import Trace, analyze +from primary_observer import PrimaryObserver, verify_negative_control as verify_primary_control from production_app_smoke import (EXECUTABLES, NS, add_provenance_arguments, digest, ground, package_owner, profile_packages, provenance as runtime_provenance) from production_mcp import DirectMCP, assert_distinct_runtimes, stop_process @@ -564,6 +565,9 @@ def provenance(args, plan): 'sha256': hashlib.sha256(path.read_bytes()).hexdigest()} for name, path in files.items()}) origin['app_processes'] = identities + if getattr(args, 'primary_observer', None): + for name in ('primary_observer.py', 'primary_observer_fixture.py'): + origin['files'][name] = digest(Path(__file__).with_name(name)) return origin @@ -586,6 +590,9 @@ def save(name, value): (args.evidence / name).write_text(json.dumps(value, indent=2)) save('plan.json', plan) clients, recorder, grab, trace = [], None, None, None + primary_observer = None + observer_started = False + control_intervals = [] primary_deadline_ns = None moving = plan.get('moving_primary', False) mover = None @@ -602,6 +609,7 @@ def save(name, value): 'app_profile': plan.get('app_profile', 'calc-inkscape'), 'full_desktop_matrix': False, 'actions': [], 'outputs': [], 'continuous_isolation': 'unproven', 'synthetic_cleanup': 'unproven', + 'independent_primary_isolation': {'result': 'unproven'}, 'primary_mode': 'moving' if moving else 'parked'} if capacity: report['capacity'] = {'result': 'unproven'} @@ -779,6 +787,14 @@ def action(step, barrier=None): assert all(current[key] == baseline[key] for key in ('clicks', 'keys', 'scroll', 'held')), current return {'agent': index, 'tool': step['tool'], **result} try: + observer_path = getattr(args, 'primary_observer', None) + if getattr(args, 'artifact_role', None) == 'production': + assert observer_path and not args.trace_socket, 'production package proof requires the independent primary observer' + if observer_path: + assert getattr(args, 'artifact_role', None) == 'production' and not args.trace_socket, \ + 'independent observer is an explicit production no-trace gate' + assert not moving and plan['purpose'] in ('apps', 'negative_control'), \ + 'independent observer currently qualifies parked app/control intervals only' assert not moving or args.trace_socket, 'moving primary requires continuous trace' assert not capacity or args.trace_socket, 'capacity requires continuous trace' assert not policy_cache or args.trace_socket, 'policy_cache requires continuous trace' @@ -816,6 +832,10 @@ def action(step, barrier=None): trace = Trace(args.trace_socket) assert trace.hello['protocol'] == 3 trace.exchange('TRACE_START') + if observer_path: + primary_observer = PrimaryObserver(observer_path, plan['foreground'], args.foreground_journal, args.evidence) + primary_observer.start(primary_before) + observer_started = True if args.record_video: video = recorder.tool('start_recording', {'output_dir': str(args.evidence / 'video'), 'record_video': True}) assert not video.get('isError') and video['structuredContent']['video_active'], video @@ -834,9 +854,14 @@ def movement(): for phase in plan['phases']: require_primary_active(grab, primary_deadline_ns) if phase.get('negative_control'): - assert trace, 'warp-and-return detector requires continuous trace' + assert trace or observer_started, 'warp-and-return detector requires continuous evidence' + if observer_started: + assert point[0] + 40 < fg['width'] and point[1] + 30 < fg['height'], \ + 'independent canary must remain inside the primary fixture' snapshot(recorder, plan['foreground']) + control_start = time.monotonic_ns() subprocess.run(grab_args + ['100', 'canary'], check=True, timeout=10) + control_intervals.append((control_start, time.monotonic_ns())) snapshot(recorder, plan['foreground']) assert wm() == primary_before, 'control failed to return to identical endpoints' elif 'parallel' in phase: @@ -891,6 +916,20 @@ def stop_video(): assert not result.get('isError') and not result['structuredContent'].get('last_error'), result operations.append(('stop_video', stop_video)) operations.extend((f'close_agent_{i}', mcp.close) for i, mcp in enumerate(clients)) + if primary_observer: + def finish_primary_observer(): + assert observer_started, 'independent observer baseline failed' + require_primary_active(grab, primary_deadline_ns) + result = primary_observer.finish(action_intervals + control_intervals, wm()) + require_primary_active(grab, primary_deadline_ns) + report['independent_primary_isolation'] = result + if plan['purpose'] == 'negative_control': + report['independent_primary_control'] = verify_primary_control(result) + report['negative_control_detected'] = True + else: + assert result['result'] == 'passed', result + operations += [('finish_primary_observer', finish_primary_observer), + ('close_primary_observer', primary_observer.close)] if trace: def finish_trace(): focus_after = None @@ -978,7 +1017,9 @@ def join_motion(): report['result'] = 'failed' elif report['result'] == 'passed' and not trace: report['scope'] = 'production-package-smoke' - if plan['purpose'] != 'apps' or plan.get('require_overlap'): + if observer_started and plan['purpose'] == 'negative_control': + report['scope'] = 'production-package-primary-control' + elif plan['purpose'] != 'apps' or plan.get('require_overlap'): report['result'] = 'inconclusive' save('result.json', report) print(json.dumps(report), flush=True) @@ -991,5 +1032,6 @@ def join_motion(): parser.add_argument('--' + name, required=True, type=Path) add_provenance_arguments(parser) parser.add_argument('--trace-socket', type=Path) + parser.add_argument('--primary-observer', type=Path) parser.add_argument('--record-video', action='store_true') raise SystemExit(run(parser.parse_args())) From bd1324297f951f72040fd3347dad6164874ba7e0 Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Wed, 9 Sep 2026 21:44:40 -0500 Subject: [PATCH 06/27] test(cua-driver): parse native Wayland clock timestamps --- .../hyprland-plugin/tests/primary_observer.py | 3 +- .../tests/production_primary_observer_test.py | 31 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/libs/cua-driver/hyprland-plugin/tests/primary_observer.py b/libs/cua-driver/hyprland-plugin/tests/primary_observer.py index 7a135e1113..e2b2d95aa1 100644 --- a/libs/cua-driver/hyprland-plugin/tests/primary_observer.py +++ b/libs/cua-driver/hyprland-plugin/tests/primary_observer.py @@ -16,7 +16,8 @@ MAX_RECORDS = 100000 MAX_GAP_NS = 1_000_000_000 MAX_INTERVAL_NS = 60_000_000_000 -WIRE = re.compile(r'^\[\s*(\d+\.\d+)\]\s*(?:\{[^}]+\}\s*)?(?P->\s*)?' +WIRE = re.compile(r'^\[\s*(\d+\.\d+|(?:[01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]\.[0-9]+)\]' + r'\s*(?:\{[^}]+\}\s*)?(?P->\s*)?' r'(?P\w+)[#@](?P\d+)\.(?P\w+)\((?P.*)\)$') diff --git a/libs/cua-driver/hyprland-plugin/tests/production_primary_observer_test.py b/libs/cua-driver/hyprland-plugin/tests/production_primary_observer_test.py index 9505f76624..e12c4f8ba0 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production_primary_observer_test.py +++ b/libs/cua-driver/hyprland-plugin/tests/production_primary_observer_test.py @@ -52,6 +52,37 @@ def row(seq, kind, when, **values): class ObserverAnalysisTests(unittest.TestCase): + def test_native_clock_timestamps_preserve_wire_event_fields(self): + data = (b'[02:42:49.666527] {Default Queue} -> xdg_wm_base#42.pong(1337)\n' + b'[02:42:51.166558] {Default Queue} xdg_wm_base#42.ping(1337)\n') + self.assertEqual(wire_rows(data), [ + {'out': True, 'interface': 'xdg_wm_base', 'object': 42, 'event': 'pong', 'arguments': '1337'}, + {'out': False, 'interface': 'xdg_wm_base', 'object': 42, 'event': 'ping', 'arguments': '1337'}, + ]) + + def test_native_clock_boundaries_and_legacy_numeric_timestamps(self): + for timestamp in (b'00:00:00.0', b'23:59:59.999999', b'19:09:09.123', + b'0.0', b'12345.000', b' 123456789.123456'): + with self.subTest(timestamp=timestamp): + native_sync = SYNC.replace(b'12345.000', timestamp) + self.assertEqual(wire_rows(native_sync), wire_rows(SYNC)) + sync_barrier(native_sync) + self.assertEqual(primary_wire_state(BASE.replace(b'12345.000', timestamp)), + primary_wire_state(BASE)) + with self.assertRaisesRegex(AssertionError, 'missing complete'): + sync_barrier(native_sync.replace(b'wl_callback#21.done', b'wl_callback#22.done')) + + def test_malformed_timestamps_fail_without_skipping_records(self): + for timestamp in (b'24:00:00.000000', b'99:00:00.000000', b'00:60:00.000000', + b'00:00:60.000000', b'2:42:49.666527', b'02:2:49.666527', + b'02:42:9.666527', b'02:42:49', b'02:42:49.', b'02:42:49.x', + b'02:42:49.123Z', b'-02:42:49.123', b'02:42:49.123 ', + b'12345', b'12345.', b'.000', b'-12345.000'): + with self.subTest(timestamp=timestamp): + bad = CANARY.replace(b'12345.000', timestamp, 1) + with self.assertRaisesRegex(AssertionError, 'unparseable Wayland wire record'): + wire_rows(SYNC + bad + SYNC) + def test_normal_interval_passes_without_claiming_compositor_attribution(self): result = analyze(*observation()) self.assertEqual(result['result'], 'passed') From d083bb68ae204865d4e70fffc379f2ebf063039d Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Wed, 9 Sep 2026 21:50:13 -0500 Subject: [PATCH 07/27] test(cua-driver): retain known destroyed-buffer wire events --- .../hyprland-plugin/tests/primary_observer.py | 31 +++++++++++++++++-- .../tests/production_primary_observer_test.py | 18 +++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/libs/cua-driver/hyprland-plugin/tests/primary_observer.py b/libs/cua-driver/hyprland-plugin/tests/primary_observer.py index e2b2d95aa1..5826ab59d6 100644 --- a/libs/cua-driver/hyprland-plugin/tests/primary_observer.py +++ b/libs/cua-driver/hyprland-plugin/tests/primary_observer.py @@ -16,18 +16,43 @@ MAX_RECORDS = 100000 MAX_GAP_NS = 1_000_000_000 MAX_INTERVAL_NS = 60_000_000_000 -WIRE = re.compile(r'^\[\s*(\d+\.\d+|(?:[01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]\.[0-9]+)\]' +TIMESTAMP = r'\d+\.\d+|(?:[01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]\.[0-9]+' +WIRE = re.compile(r'^\[\s*(' + TIMESTAMP + r')\]' r'\s*(?:\{[^}]+\}\s*)?(?P->\s*)?' r'(?P\w+)[#@](?P\d+)\.(?P\w+)\((?P.*)\)$') +DISCARDED_BUFFER = re.compile(r'^\[\s*(?:' + TIMESTAMP + r')\] discarded \[unknown\][#@](\d+)\.\[event 0\]\(0 fd, 8 byte\)$') def wire_rows(data): assert len(data) <= MAX_BYTES and (not data or data.endswith(b'\n')), 'incomplete or oversized wire log' - rows = [] + rows, objects, destroyed_buffers = [], {}, set() for line in data.decode('utf-8').splitlines(): match = WIRE.fullmatch(line) + if not match: + discarded = DISCARDED_BUFFER.fullmatch(line) + assert discarded and int(discarded[1]) in destroyed_buffers, 'unparseable Wayland wire record' + # libwayland labels a queued release unknown after GTK destroyed + # its buffer proxy. Accept only the fully observed buffer lifetime; + # an unknown, reused, deleted or input object still fails closed. + obj = int(discarded[1]) + destroyed_buffers.remove(obj) + rows.append({'out': False, 'interface': 'wl_buffer', 'object': obj, + 'event': 'discarded_release', 'arguments': '0 fd, 8 byte'}) + continue assert match, 'unparseable Wayland wire record' - rows.append({**match.groupdict(), 'object': int(match['object']), 'out': match['out'] is not None}) + row = {**match.groupdict(), 'object': int(match['object']), 'out': match['out'] is not None} + for interface, identifier in re.findall(r'new id (\w+)[#@](\d+)', row['arguments']): + obj = int(identifier) + objects[obj] = interface + destroyed_buffers.discard(obj) + if row['out'] and row['interface'] == 'wl_buffer' and row['event'] == 'destroy': + if objects.get(row['object']) == 'wl_buffer': + destroyed_buffers.add(row['object']) + if not row['out'] and row['interface'] == 'wl_display' and row['event'] == 'delete_id': + obj = int(row['arguments']) + objects.pop(obj, None) + destroyed_buffers.discard(obj) + rows.append(row) assert len(rows) <= MAX_RECORDS, 'wire event limit exceeded' return rows diff --git a/libs/cua-driver/hyprland-plugin/tests/production_primary_observer_test.py b/libs/cua-driver/hyprland-plugin/tests/production_primary_observer_test.py index e12c4f8ba0..6ac8f38c94 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production_primary_observer_test.py +++ b/libs/cua-driver/hyprland-plugin/tests/production_primary_observer_test.py @@ -52,6 +52,24 @@ def row(seq, kind, when, **values): class ObserverAnalysisTests(unittest.TestCase): + def test_discarded_buffer_release_requires_an_exact_observed_lifetime(self): + created = wire(' -> wl_shm_pool#58.create_buffer(new id wl_buffer#59, 0, 466, 249, 1864, 0)') + destroyed = wire(' -> wl_buffer#59.destroy()') + discarded = b'[02:42:17.463629] discarded [unknown]#59.[event 0](0 fd, 8 byte)\n' + parsed = wire_rows(created + destroyed + discarded) + self.assertEqual(parsed[-1], {'out': False, 'interface': 'wl_buffer', 'object': 59, + 'event': 'discarded_release', 'arguments': '0 fd, 8 byte'}) + for data in (discarded, created + discarded, destroyed + discarded, + created + destroyed + discarded + discarded, + created + destroyed + wire('wl_display#1.delete_id(59)') + discarded, + created + destroyed + wire(' -> wl_seat#4.get_pointer(new id wl_pointer#59)') + discarded, + created.replace(b'wl_buffer', b'wl_pointer') + destroyed + discarded, + created + destroyed + discarded.replace(b'event 0', b'event 1'), + created + destroyed + discarded.replace(b'0 fd', b'1 fd'), + created + destroyed + discarded.replace(b'8 byte', b'12 byte')): + with self.subTest(data=data), self.assertRaisesRegex(AssertionError, 'unparseable'): + wire_rows(data) + def test_native_clock_timestamps_preserve_wire_event_fields(self): data = (b'[02:42:49.666527] {Default Queue} -> xdg_wm_base#42.pong(1337)\n' b'[02:42:51.166558] {Default Queue} xdg_wm_base#42.ping(1337)\n') From 6cb6c989af7f5bb592dad82adc62da8e938a5ad6 Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Wed, 9 Sep 2026 21:55:22 -0500 Subject: [PATCH 08/27] test(cua-driver): add opt-in Inkscape fault qualification profile --- .../tests/production_active_lock_proof.py | 22 ++-- .../tests/production_active_primary_proof.py | 19 +-- .../tests/production_cancel_proof.py | 39 +++++- .../tests/production_cancel_proof_test.py | 116 ++++++++++++++++++ .../tests/production_geometry_fault_proof.py | 3 +- .../tests/production_idle_reconnect_proof.py | 46 ++++--- .../production_idle_reconnect_proof_test.py | 22 ++++ .../tests/production_lock_refusal_proof.py | 13 +- .../tests/production_session_fault_proof.py | 15 +-- .../production_session_fault_proof_test.py | 2 +- .../tests/production_target_lifetime_proof.py | 60 ++++++--- .../production_target_lifetime_proof_test.py | 18 +++ 12 files changed, 311 insertions(+), 64 deletions(-) diff --git a/libs/cua-driver/hyprland-plugin/tests/production_active_lock_proof.py b/libs/cua-driver/hyprland-plugin/tests/production_active_lock_proof.py index 9e6fe0fa72..55bceb62ff 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production_active_lock_proof.py +++ b/libs/cua-driver/hyprland-plugin/tests/production_active_lock_proof.py @@ -45,12 +45,16 @@ def validate_plan(plan): assert plan['purpose'] == 'active_lock' and plan['fault'] == {'kind': 'lock'} - assert plan['recovery'] == {'pointer_stages': ['click_a1', 'click_b2']} - lock_plan({**plan, 'purpose': 'lock_refusal', 'recovery': {'pointer_stage': 'click_b2'}}) + stages = ['scroll_down', 'scroll_up'] if plan.get('app_profile') == 'inkscape-only' else ['click_a1', 'click_b2'] + assert plan['recovery'] == {'pointer_stages': stages} + lock_plan({**plan, 'purpose': 'lock_refusal', 'recovery': {'pointer_stage': stages[0]}}) -def recovery_stage(snapshot): - """Choose one newly grounded click, never retry a sent action.""" +def recovery_stage(snapshot, app='calc'): + """Choose a fresh Calc click or visible Inkscape scroll, never retry input.""" + if app == 'inkscape': + return pointer_grounding.visible_inkscape_scroll_stage(snapshot, pointer_grounding.read_pixels(snapshot['proof_image'])) + assert app == 'calc' return ('click_a1' if pointer_grounding.calc_formula_selection( snapshot, pointer_grounding.rows(snapshot), 'B2') else 'click_b2') @@ -59,10 +63,10 @@ def prepare_recovery(client, spec, allowed_stages): before = grounded_snapshot(client, spec['target'], spec) prepared_ns = before['proof_observation_started_ns'] assert type(prepared_ns) is int and 0 < prepared_ns <= time.monotonic_ns(), 'invalid observation timestamp' - stage = recovery_stage(before) + stage = recovery_stage(before, spec['app']) assert stage in allowed_stages arguments, oracle = pointer_grounding.action( - before, pointer_grounding.read_pixels(before['proof_image']), 'calc', stage) + before, pointer_grounding.read_pixels(before['proof_image']), spec['app'], stage) return {'snapshot': before, 'arguments': arguments, 'oracle': oracle, 'stage': stage, 'prepared_ns': prepared_ns} @@ -350,7 +354,8 @@ def start_trace(): recovery['stage'] = spec['pointer_stage'] = grounding['stage'] save('recovery-grounding.json', grounding) require_primary_active(grab, deadline) - recovery['action'] = {'outcome': 'unknown', 'replayed': False, 'prepared_ns': prepared_ns} + recovery['action'] = {'outcome': 'unknown', 'replayed': False, 'prepared_ns': prepared_ns, + 'tool': pointer_grounding.STAGES[spec['app']][spec['pointer_stage']]} response = click_once(fresh, {**arguments, **spec['target'], 'session': spec['name'], 'delivery_mode': 'background'}, recovery['action'], save, 'recovery-action.json') check_response(response, {'kind': 'dispatched'}) @@ -359,7 +364,8 @@ def start_trace(): recovery['app_effect'] = pointer_grounding.verify(after, pointer_grounding.read_pixels(after['proof_image']), oracle) prefix = trace.collect() save('recovery-trace-prefix.json', prefix) - recovery['trace'] = verify_recovery_trace(initial, prefix, capacity_lane(initial, prefix, 'click'), 'click') + tool = recovery['action']['tool'] + recovery['trace'] = verify_recovery_trace(initial, prefix, capacity_lane(initial, prefix, tool), tool) close_owned(fresh) trace.exchange('TRACE_STOP') stopped = trace.collect() diff --git a/libs/cua-driver/hyprland-plugin/tests/production_active_primary_proof.py b/libs/cua-driver/hyprland-plugin/tests/production_active_primary_proof.py index 089f58cfef..1581d2fc50 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production_active_primary_proof.py +++ b/libs/cua-driver/hyprland-plugin/tests/production_active_primary_proof.py @@ -77,11 +77,12 @@ def verify_transition_end(boundary, stopped): def validate_plan(plan): assert plan['purpose'] == 'active_primary' and plan['case'] == 'active_drag' assert plan['fault'] == {'kind': 'primary_hover'} - assert plan['recovery'] == {'pointer_stages': ['click_a1', 'click_b2']} + stages = ['scroll_down', 'scroll_up'] if plan.get('app_profile') == 'inkscape-only' else ['click_a1', 'click_b2'] + assert plan['recovery'] == {'pointer_stages': stages} candidate = {k: v for k, v in plan.items() if k != 'fault'} settled_plan({**candidate, 'purpose': 'primary_conflict', 'case': 'initial_refusal', - 'recovery': {'pointer_stage': 'click_b2'}}) - assert plan['agents'][0]['app'] == 'calc' + 'recovery': {'pointer_stage': stages[0]}}) + assert plan['agents'][0]['app'] == ('inkscape' if plan.get('app_profile') == 'inkscape-only' else 'calc') expected = plan['hover_fixture'] assert set(expected) == {'path', 'device', 'inode', 'uid', 'sha256', 'source_sha256'} assert Path(expected['path']).is_absolute() and Path(expected['path']).name == 'primary_hover_fixture' @@ -298,9 +299,9 @@ def prepare_recovery(client, spec, previous, allowed_stages): started_ns = time.monotonic_ns() before = grounded_snapshot(client, spec['target'], spec) verify_fresh_observation(previous, before, client, after_ns=started_ns) - stage = recovery_stage(before) + stage = recovery_stage(before, spec['app']) assert stage in allowed_stages - arguments, oracle = pointer_grounding.action(before, pointer_grounding.read_pixels(before['proof_image']), 'calc', stage) + arguments, oracle = pointer_grounding.action(before, pointer_grounding.read_pixels(before['proof_image']), spec['app'], stage) return {'snapshot': before, 'arguments': arguments, 'oracle': oracle, 'stage': stage, 'prepared_ns': before['proof_observation_started_ns']} @@ -341,7 +342,7 @@ def start_trace(): validate_plan(plan) desktop = ExactDesktop(plan) spec = plan['agents'][0] - app_process_identity('calc', spec['target']['pid']) + app_process_identity(spec['app'], spec['target']['pid']) origin = provenance(args, plan) for name in (Path(__file__).name, 'production_active_primary_proof_test.py', 'primary_hover_fixture.c', 'primary_hover_fixture_test.py', @@ -417,7 +418,8 @@ def start_trace(): restored.guard() assert desktop.primary(plan['foreground']) == primary recovery['action'] = {'outcome': 'unknown', 'replayed': False, 'prepared_ns': prepared_ns, - 'runtime_pid': fresh.process.pid} + 'runtime_pid': fresh.process.pid, + 'tool': pointer_grounding.STAGES[spec['app']][spec['pointer_stage']]} response = click_once(fresh, {**arguments, **spec['target'], 'session': spec['name'], 'delivery_mode': 'background'}, recovery['action'], save, 'recovery-action.json') check_response(response, {'kind': 'dispatched'}) @@ -429,7 +431,8 @@ def start_trace(): recovery['app_effect'] = pointer_grounding.verify(after, pointer_grounding.read_pixels(after['proof_image']), oracle) prefix = trace.collect() save('recovery-prefix.json', prefix) - recovery['trace'] = verify_recovery_trace(initial, prefix, capacity_lane(initial, prefix, 'click'), 'click') + tool = recovery['action']['tool'] + recovery['trace'] = verify_recovery_trace(initial, prefix, capacity_lane(initial, prefix, tool), tool) close_owned(fresh) trace.exchange('TRACE_STOP') stopped = trace.collect() diff --git a/libs/cua-driver/hyprland-plugin/tests/production_cancel_proof.py b/libs/cua-driver/hyprland-plugin/tests/production_cancel_proof.py index 0be52cc7aa..8b1e171a5b 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production_cancel_proof.py +++ b/libs/cua-driver/hyprland-plugin/tests/production_cancel_proof.py @@ -18,7 +18,7 @@ from primary_trace import Trace, analyze from production_mcp import DirectMCP, assert_distinct_runtimes, stop_process import production_pointer_grounding as pointer_grounding -from production_realapp_proof import (app_process_identity, capacity_lane, check_response, +from production_realapp_proof import (app_process_identity, inkscape_client_identity, capacity_lane, check_response, primary_acknowledgement, provenance, trace_interval, PRIMARY_LIFETIME_MS, require_primary_active) from realapp_proof import cleanup_all, released_synthetic_input @@ -27,7 +27,7 @@ PROFILE = {'mode': 'unrestricted', 'acknowledge_unrestricted': True} DRAG_KEYS = {'from_x', 'from_y', 'to_x', 'to_y', 'duration_ms'} POINTER_STAGES = {'calc': 'select_range', 'inkscape': 'move_rectangle'} -RECOVERY_STAGES = {'calc': {'click_a1', 'click_b2'}, 'inkscape': {'scroll_down', 'scroll_visible'}} +RECOVERY_STAGES = {'calc': {'click_a1', 'click_b2'}, 'inkscape': {'scroll_down', 'scroll_up', 'scroll_visible'}} MAX_GROUNDING_AGE_NS = 5_000_000_000 # Leave margin over the observed 25–30 ms lane-admission interval. This is not # a worst-case scheduling bound: call_drag still checks the actual age. @@ -42,12 +42,41 @@ POINTER_SNAPSHOT_LIMITS = {'inkscape': {'max_elements': 2500}} +def validate_app_profile(plan, *, require_drag=True): + """Opt-in test profile only; product app admission and two lanes stay fixed.""" + profile = plan.get('app_profile', 'calc-inkscape') + assert profile in ('calc-inkscape', 'inkscape-only'), 'unknown app profile' + if profile == 'inkscape-only': + specs = plan['agents'] + assert all(spec['app'] == 'inkscape' for spec in specs), 'inkscape-only requires Inkscape targets' + documents = [Path(spec['document']) for spec in specs] + assert all(path.is_absolute() and path.name == 'cua-smoke-inkscape.svg' for path in documents), \ + 'Inkscape requires an absolute synthetic SVG document' + assert len({path.resolve() for path in documents}) == len(documents), 'distinct documents required' + targets = [plan['foreground'], *(spec['target'] for spec in specs)] + assert len({target['window_id'] for target in targets}) == len(targets), 'distinct native windows required' + if require_drag: + assert all(spec.get('pointer_stage') == 'move_rectangle' and spec.get('drag') == {} for spec in specs), \ + 'Inkscape faults require freshly grounded rectangle drags' + recovery = plan.get('recovery', {}) + if 'pointer_stage' in recovery: + allowed = {'scroll_down', 'scroll_up'} + if plan['purpose'] == 'cancellation': + allowed.add('scroll_visible') + assert recovery['pointer_stage'] in allowed, 'new Inkscape recovery requires a supported scroll stage' + for key in ('identities', 'processes'): + if key in plan: + assert plan[key]['target']['exe'] == '/usr/bin/inkscape', 'wrong Inkscape executable identity' + return profile + + def validate_plan(plan): + profile = validate_app_profile(plan) assert plan['purpose'] == 'cancellation' assert type(plan['kill_agent']) is int and plan['kill_agent'] in (0, 1) assert plan.get('termination_signal', 'SIGKILL') in ('SIGKILL', 'SIGTERM'), 'unsupported termination signal' assert len(plan['agents']) == 2 - assert {spec['app'] for spec in plan['agents']} == {'calc', 'inkscape'} + assert {spec['app'] for spec in plan['agents']} == ({'inkscape'} if profile == 'inkscape-only' else {'calc', 'inkscape'}) targets = [plan['foreground'], *(spec['target'] for spec in plan['agents'])] assert len({target['pid'] for target in targets}) == 3, 'need three separate app processes' for target in targets: @@ -196,6 +225,10 @@ def verify_fresh_observation(before, after, observer, *, after_ns): def grounded_snapshot(client, target, spec=None, *, session=True): + if spec and spec['app'] == 'inkscape' and 'document' in spec: + assert spec['target'] == target, 'observation target differs from reviewed Inkscape client' + native = json.loads(subprocess.check_output(['hyprctl', '-j', 'clients'], text=True, timeout=2)) + inkscape_client_identity(spec, native) windows = client.tool('list_windows', {}) assert not windows.get('isError'), windows matches = [w for w in windows['structuredContent']['windows'] if w.get('pid') == target['pid']] diff --git a/libs/cua-driver/hyprland-plugin/tests/production_cancel_proof_test.py b/libs/cua-driver/hyprland-plugin/tests/production_cancel_proof_test.py index c06f27a2a1..e1ec63cb00 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production_cancel_proof_test.py +++ b/libs/cua-driver/hyprland-plugin/tests/production_cancel_proof_test.py @@ -1,6 +1,8 @@ """Synthetic orchestration/telemetry tests only; no native desktop is exercised.""" from contextlib import ExitStack from concurrent.futures import ThreadPoolExecutor +from copy import deepcopy +import importlib import json from pathlib import Path import subprocess @@ -68,6 +70,120 @@ def recovery_rows(tool='click', lane=1): return FINISH[:-1] + [(2001, 'agent_admitted', lane, 0), *inputs, (2012, 'agent_action_end', lane, 0)] +class InkscapeProfileTests(unittest.TestCase): + def candidates(self): + for name in ('cancel', 'geometry_fault', 'primary_conflict', 'desktop_fault', 'session_fault', + 'lock_refusal', 'active_lock', 'active_primary', 'idle_reconnect', 'target_lifetime'): + module = importlib.import_module('production_' + name + '_proof') + candidate = importlib.import_module('production_' + name + '_proof_test').plan() + candidate['app_profile'] = 'inkscape-only' + for i, spec in enumerate(candidate['agents']): + spec.update(app='inkscape', document=f'/synthetic/agent-{i}/cua-smoke-inkscape.svg') + if name != 'idle_reconnect': + spec.update(pointer_stage='move_rectangle', drag={}) + for key in ('identities', 'processes'): + if key in candidate: + candidate[key]['target']['exe'] = '/usr/bin/inkscape' + if name in ('active_lock', 'active_primary'): + candidate['recovery'] = {'pointer_stages': ['scroll_down', 'scroll_up']} + elif name == 'target_lifetime': + old, fresh = candidate['agents'][0], candidate['recovery']['agent'] + fresh.update(app='inkscape', document='/synthetic/replacement/cua-smoke-inkscape.svg', + pointer_stage='click_rectangle') + candidate['recovery']['identity']['exe'] = '/usr/bin/inkscape' + for i, spec in enumerate((old, fresh)): + spec['app_id_tag'] = f'cua-profile-lane-{i}' + spec['owned'].pop('profile') + spec['owned']['document']['path'] = spec['document'] + elif name != 'idle_reconnect': + candidate['recovery'] = {'pointer_stage': 'scroll_down'} + yield name, module, candidate + + def test_all_fault_profiles_accept_only_exact_inkscape_plan_shapes(self): + for name, module, candidate in self.candidates(): + with self.subTest(name=name): + module.validate_plan(candidate) + mutations = [lambda p: p.update(app_profile='unreviewed'), + lambda p: p['agents'][0].update(app='calc'), + lambda p: p['agents'][0].update(pointer_stage='click_a1'), + lambda p: p['agents'][0].update(document='/synthetic/private.svg'), + lambda p: p['agents'][0]['target'].update(window_id=p['foreground']['window_id'])] + for key in ('identities', 'processes'): + if key in candidate: + mutations.append(lambda p, k=key: p[k]['target'].update(exe='/usr/bin/soffice.bin')) + mutations.append(lambda p, k=key: p[k]['target'].update(pid=999)) + if name == 'target_lifetime': + mutations.extend([lambda p: p['recovery']['agent'].update(pointer_stage='move_rectangle'), + lambda p: p['recovery']['identity'].update(exe='/usr/bin/soffice.bin'), + lambda p: p['recovery']['agent'].update(app_id_tag=p['agents'][0]['app_id_tag']), + lambda p: p['recovery']['agent']['owned']['document'].update(path='/synthetic/wrong.svg')]) + for mutate in mutations: + changed = deepcopy(candidate) + mutate(changed) + with self.subTest(name=name, changed=changed), self.assertRaises(AssertionError): + module.validate_plan(changed) + + def test_scroll_recovery_uses_existing_pixel_and_semantic_oracle(self): + import production_active_lock_proof as active + from production_pointer_grounding_test import ink + before, pixels = ink() + before.update(proof_image='synthetic.png', proof_observation_started_ns=1) + spec = {'app': 'inkscape', 'name': 'recovery', 'target': {'pid': 20, 'window_id': 200}, + 'pointer_stage': 'move_rectangle'} + with patch.object(active, 'grounded_snapshot', return_value=before), \ + patch.object(active.pointer_grounding, 'read_pixels', return_value=pixels): + prepared = active.prepare_recovery(Mock(), spec, ['scroll_down', 'scroll_up']) + self.assertIn(prepared['stage'], ('scroll_down', 'scroll_up')) + self.assertEqual(prepared['prepared_ns'], 1) + with self.assertRaises(AssertionError): + active.pointer_grounding.verify(before, pixels, prepared['oracle']) + shift = -10 if prepared['stage'] == 'scroll_down' else 10 + after, moved = ink(scroll_y=shift) + self.assertTrue(active.pointer_grounding.verify(after, moved, prepared['oracle'])['verified']) + + def test_refusal_scroll_keeps_exact_target_and_original_observation(self): + import production_session_fault_proof as session + from production_pointer_grounding_test import ink + before, pixels = ink() + target = {'pid': 20, 'window_id': 200} + before.update(**target, proof_image='synthetic.png', proof_observation_started_ns=1) + spec = {'app': 'inkscape', 'name': 'refusal', 'target': target, 'bounds': before['window_bounds']} + prepared = {'snapshot': before, 'target': dict(target), 'prepared_ns': 1} + with patch.object(session.pointer_grounding, 'read_pixels', return_value=pixels): + probe = session.prepare_refusal(prepared, spec, 'scroll_down') + self.assertEqual(probe['tool'], 'scroll') + self.assertEqual(probe['prepared_ns'], 1) + before['window_id'] += 1 + with self.assertRaises(AssertionError): + session.prepare_refusal(prepared, spec, 'scroll_down') + + def test_new_action_dispatch_uses_scroll_without_drag_or_replay(self): + import production_lock_refusal_proof as lock + for tool in ('click', 'scroll', 'drag'): + actor = Mock() + record = {'outcome': 'unknown', 'replayed': False, 'prepared_ns': 1, 'tool': tool} + with patch.object(lock.time, 'monotonic_ns', return_value=2): + if tool == 'drag': + with self.assertRaises(AssertionError): + lock.click_once(actor, {'x': 1}, record, Mock(), 'action') + actor.tool.assert_not_called() + else: + lock.click_once(actor, {'x': 1}, record, Mock(), 'action') + actor.tool.assert_called_once_with(tool, {'x': 1}) + + def test_native_document_identity_is_checked_before_driver_observation(self): + import production_cancel_proof as cancellation + actor = Mock() + spec = {'app': 'inkscape', 'document': '/synthetic/cua-smoke-inkscape.svg', + 'target': {'pid': 20, 'window_id': 200}} + with patch.object(cancellation.subprocess, 'check_output', return_value='[]'), \ + patch.object(cancellation, 'inkscape_client_identity', side_effect=AssertionError('wrong identity')) as identity: + with self.assertRaisesRegex(AssertionError, 'wrong identity'): + grounded_snapshot(actor, {'pid': 20, 'window_id': 200}, spec) + identity.assert_called_once_with(spec, []) + actor.tool.assert_not_called() + + class TelemetryTests(unittest.TestCase): def test_active_drags_accepts_surface_coordinates_without_losing_lane_state(self): self.assertEqual(active_drags(wire_trace(OVERLAP)), active_drags(trace(OVERLAP))) diff --git a/libs/cua-driver/hyprland-plugin/tests/production_geometry_fault_proof.py b/libs/cua-driver/hyprland-plugin/tests/production_geometry_fault_proof.py index 47e10149fd..5a4edc1355 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production_geometry_fault_proof.py +++ b/libs/cua-driver/hyprland-plugin/tests/production_geometry_fault_proof.py @@ -33,7 +33,7 @@ from primary_trace import Trace, analyze from production_cancel_proof import (GROUNDING_DISPATCH_RESERVE_NS, MAX_GROUNDING_ATTEMPTS, MAX_GROUNDING_AGE_NS, POINTER_STAGES, PROFILE, - RECOVERY_STAGES, active_drags, call_drag, close_owned, grounded_snapshot, + RECOVERY_STAGES, active_drags, call_drag, close_owned, grounded_snapshot, validate_app_profile, poll_active, prepare_drag, stopped_prefix, verify_recovery_cleanup, verify_recovery_trace) from production_mcp import DirectMCP, assert_distinct_runtimes, stop_process import production_pointer_grounding as pointer_grounding @@ -43,6 +43,7 @@ def validate_plan(plan): + validate_app_profile(plan) assert plan['purpose'] == 'geometry_fault' and plan['disposable'] is True assert len(plan['agents']) == 1, 'geometry episode owns one synthetic lane' spec = plan['agents'][0] diff --git a/libs/cua-driver/hyprland-plugin/tests/production_idle_reconnect_proof.py b/libs/cua-driver/hyprland-plugin/tests/production_idle_reconnect_proof.py index bcd3e4341c..ea77d04ea2 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production_idle_reconnect_proof.py +++ b/libs/cua-driver/hyprland-plugin/tests/production_idle_reconnect_proof.py @@ -1,6 +1,8 @@ """Opt-in production idle-peer regression in a prepared disposable Hyprland desktop. Two new, grounded Calc clicks share one DirectMCP runtime and named session. +The opt-in inkscape-only profile uses scroll_down then scroll_up on a selected +synthetic rectangle; both require observed pixel and semantic effects. Between them only read-only compositor status is polled, for at most 85 seconds. Neither a primary grab nor a trace spans the real 60-second input-peer expiry. No transport reset, test input packet, timeout override, or action retry is used. @@ -17,7 +19,7 @@ from driver_input_live import state, wait_for, wm from primary_trace import Trace from production_cancel_proof import (MAX_GROUNDING_AGE_NS, PROFILE, close_owned, - grounded_snapshot, verify_recovery_cleanup, + grounded_snapshot, validate_app_profile, verify_recovery_cleanup, verify_recovery_trace) from production_mcp import DirectMCP, assert_distinct_runtimes, stop_process import production_pointer_grounding as grounding @@ -33,15 +35,20 @@ # budget before starting another read, so a stalled hyprctl cannot extend 85s. STATUS_READ_BUDGET_NS = 5_000_000_000 STAGES = ('click_b2', 'click_a1') +INKSCAPE_STAGES = ('scroll_down', 'scroll_up') def validate_plan(plan): + profile = validate_app_profile(plan, require_drag=False) assert plan['purpose'] == 'idle_reconnect' assert len(plan['agents']) == 1, 'one persistent input runtime is required' spec = plan['agents'][0] - assert spec['app'] == 'calc' and spec.get('profile', PROFILE) == PROFILE + assert spec['app'] == ('inkscape' if profile == 'inkscape-only' else 'calc') and spec.get('profile', PROFILE) == PROFILE assert isinstance(spec['name'], str) and spec['name'] - assert Path(spec['document']).name == 'cua-smoke-calc.ods', 'only the synthetic document is allowed' + document_name = 'cua-smoke-inkscape.svg' if profile == 'inkscape-only' else 'cua-smoke-calc.ods' + assert Path(spec['document']).name == document_name, 'only the synthetic document is allowed' + if profile == 'inkscape-only': + assert 'pointer_stage' not in spec and 'drag' not in spec, 'idle stages are fixed, fresh scroll_down then scroll_up' assert set(spec['bounds']) == {'x', 'y', 'width', 'height'} assert all(type(v) in (int, float) and math.isfinite(v) for v in spec['bounds'].values()) assert spec['bounds']['width'] > 0 and spec['bounds']['height'] > 0 @@ -60,7 +67,7 @@ def process_birth(pid, proc_root=Path('/proc')): def calc_identity(spec): pid = spec['target']['pid'] - identity = app_process_identity('calc', pid) + identity = app_process_identity(spec['app'], pid) document = Path(spec['document']).resolve(strict=True) assert str(document).encode() in Path(f'/proc/{pid}/cmdline').read_bytes().split(b'\0'), \ 'Calc process is not bound to the synthetic document' @@ -128,8 +135,15 @@ def wait_for_idle_expiry(client, runtime, occupied_status, lane, dispatch_ns, sa raise AssertionError('input peer did not expire within 85 seconds; no second action') -def grid_digest(snapshot, pixels): - x, y, width, height = grounding.calc_table(snapshot, pixels) +def grid_digest(snapshot, pixels, app='calc'): + if app == 'inkscape': + # The viewport must visibly change in addition to the strict semantic + # and rectangle-translation oracle; no acknowledgement-only pass. + grounding.blue_rectangle(snapshot, pixels) + x, y, width, height = 0, 0, pixels.width, pixels.height + else: + assert app == 'calc' + x, y, width, height = grounding.calc_table(snapshot, pixels) digest = hashlib.sha256() for row in range(y, y + height): offset = row * pixels.stride + x * pixels.channels @@ -139,14 +153,15 @@ def grid_digest(snapshot, pixels): def click_once(client, observer, spec, stage, runtime, identity, trace, boundary, save, result, guard): """Exactly one click invocation; unknown outcomes remain failures without replay.""" - assert stage in STAGES + assert stage in (INKSCAPE_STAGES if spec['app'] == 'inkscape' else STAGES) require_runtime(client, runtime) assert calc_identity(spec) == identity, 'Calc process identity changed' fresh = {**spec, 'pointer_stage': stage} before = grounded_snapshot(client, spec['target'], fresh) pixels = grounding.read_pixels(before['proof_image']) - arguments, oracle = grounding.action(before, pixels, 'calc', stage) - before_digest = grid_digest(before, pixels) + arguments, oracle = grounding.action(before, pixels, spec['app'], stage) + tool = grounding.STAGES[spec['app']][stage] + before_digest = grid_digest(before, pixels, spec['app']) result.update(stage=stage, session=spec['name'], target=dict(spec['target']), runtime_pid=runtime['pid'], grounding=before, arguments=arguments, oracle=oracle, replayed=False) save(stage + '-grounding.json', result) @@ -158,7 +173,7 @@ def click_once(client, observer, spec, stage, runtime, identity, trace, boundary 'snapshot grounding expired; no input sent' result['action'] = {'outcome': 'unknown', 'dispatch_ns': dispatch_ns, 'attempts': 1, 'replayed': False} try: - response = client.tool('click', {**arguments, **spec['target'], 'session': spec['name'], + response = client.tool(tool, {**arguments, **spec['target'], 'session': spec['name'], 'delivery_mode': 'background'}) except Exception as error: result['action']['error'] = str(error) @@ -171,14 +186,14 @@ def click_once(client, observer, spec, stage, runtime, identity, trace, boundary check_response(response, {'kind': 'dispatched'}) after_pixels = grounding.read_pixels(after['proof_image']) result['app_effect'] = grounding.verify(after, after_pixels, oracle) - after_digest = grid_digest(after, after_pixels) + after_digest = grid_digest(after, after_pixels, spec['app']) assert before_digest != after_digest, 'Calc grid pixels did not change' result['pixels'] = {'before': before_digest, 'after': after_digest, 'changed': True} assert calc_identity(spec) == identity, 'Calc process identity changed' require_runtime(client, runtime) page = trace.collect() - lane = capacity_lane(boundary, page, 'click') - result['trace'] = verify_recovery_trace(boundary, page, lane, 'click') + lane = capacity_lane(boundary, page, tool) + result['trace'] = verify_recovery_trace(boundary, page, lane, tool) guard() result['result'] = 'verified' return page, lane @@ -295,12 +310,13 @@ def save(name, value): assert not response.get('isError'), response first = {} report['actions'].append(first) - episode(args, plan, client, observer, runtime, identity, STAGES[0], save, first) + stages = INKSCAPE_STAGES if spec['app'] == 'inkscape' else STAGES + episode(args, plan, client, observer, runtime, identity, stages[0], save, first) report['expiry'] = wait_for_idle_expiry(client, runtime, first['occupied_status'], first['lane'], first['action']['dispatch_ns'], save) second = {} report['actions'].append(second) - episode(args, plan, client, observer, runtime, identity, STAGES[1], save, second, final=True) + episode(args, plan, client, observer, runtime, identity, stages[1], save, second, final=True) assert second['lane'] == first['lane'], 'fresh action did not reacquire the same lane' before = lane_states(report['expiry']['status']) after = lane_states(second['occupied_status']) diff --git a/libs/cua-driver/hyprland-plugin/tests/production_idle_reconnect_proof_test.py b/libs/cua-driver/hyprland-plugin/tests/production_idle_reconnect_proof_test.py index 2b41cfbc2d..95fc65dcf4 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production_idle_reconnect_proof_test.py +++ b/libs/cua-driver/hyprland-plugin/tests/production_idle_reconnect_proof_test.py @@ -153,6 +153,28 @@ def test_one_normal_action_in_same_named_session(self): self.assertEqual(self.snapshot_mock.call_args_list[0].args[0], self.client) self.assertEqual(self.snapshot_mock.call_args_list[1].args[0], self.observer) + def test_inkscape_scrolls_require_effects_and_use_scroll_trace_contract(self): + self.spec.update(app='inkscape', document='/synthetic/cua-smoke-inkscape.svg') + for stage, changed in (('scroll_down', True), ('scroll_up', True), ('scroll_down', False)): + with self.subTest(stage=stage, changed=changed): + self.snapshot_mock.side_effect = [self.snapshot, self.snapshot] + self.digest.side_effect = ['before', 'after' if changed else 'before'] + self.client.tool.reset_mock() + self.result.clear() + if changed: + proof.click_once(self.client, self.observer, self.spec, stage, runtime(self.client), + self.identity, self.trace, {}, Mock(), self.result, Mock()) + proof.capacity_lane.assert_called_with({}, self.trace.collect.return_value, 'scroll') + proof.verify_recovery_trace.assert_called_with({}, self.trace.collect.return_value, 1, 'scroll') + self.assertTrue(self.result['app_effect']['verified']) + else: + with self.assertRaisesRegex(AssertionError, 'pixels did not change'): + proof.click_once(self.client, self.observer, self.spec, stage, runtime(self.client), + self.identity, self.trace, {}, Mock(), self.result, Mock()) + self.client.tool.assert_called_once() + self.assertEqual(self.client.tool.call_args.args[0], 'scroll') + self.assertFalse(self.result['action']['replayed']) + def test_unknown_outcome_is_observed_but_never_replayed(self): self.client.tool.side_effect = RuntimeError('closed after possible delivery') with self.assertRaisesRegex(AssertionError, 'unknown; no replay'): diff --git a/libs/cua-driver/hyprland-plugin/tests/production_lock_refusal_proof.py b/libs/cua-driver/hyprland-plugin/tests/production_lock_refusal_proof.py index d5f7775bd9..0a0c20bdb0 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production_lock_refusal_proof.py +++ b/libs/cua-driver/hyprland-plugin/tests/production_lock_refusal_proof.py @@ -272,7 +272,9 @@ def click_once(client, arguments, record, save, name): try: record['dispatch_ns'] = time.monotonic_ns() assert 0 <= record['dispatch_ns'] - record['prepared_ns'] <= MAX_GROUNDING_AGE_NS, 'grounding expired; no input sent' - response = client.tool('click', arguments) + tool = record.get('tool', 'click') + assert tool in ('click', 'scroll'), 'only a new non-drag action is allowed' + response = client.tool(tool, arguments) record.update(outcome='response', response=response) return response except Exception as error: @@ -289,9 +291,9 @@ def prepare_click(client, spec, *, session=True): prepared_ns = snapshot['proof_observation_started_ns'] assert type(prepared_ns) is int and 0 < prepared_ns <= time.monotonic_ns() arguments, oracle = pointer_grounding.action(snapshot, - pointer_grounding.read_pixels(snapshot['proof_image']), 'calc', spec['pointer_stage']) + pointer_grounding.read_pixels(snapshot['proof_image']), spec['app'], spec['pointer_stage']) return {'snapshot': snapshot, 'arguments': arguments, 'oracle': oracle, - 'prepared_ns': prepared_ns} + 'prepared_ns': prepared_ns, 'tool': pointer_grounding.STAGES[spec['app']][spec['pointer_stage']]} def prepare_refusal_click(observer, spec, save): @@ -441,7 +443,7 @@ def start_trace(): dispatched_ns = time.monotonic_ns() assert dispatched_ns - prepared_ns <= MAX_GROUNDING_AGE_NS recovery['action'] = {'outcome': 'unknown', 'replayed': False, 'dispatch_ns': dispatched_ns, - 'prepared_ns': prepared_ns} + 'prepared_ns': prepared_ns, 'tool': grounding.get('tool', 'click')} response = click_once(fresh, {**arguments, **spec['target'], 'session': name, 'delivery_mode': 'background'}, recovery['action'], save, 'recovery-action.json') check_response(response, {'kind': 'dispatched'}) @@ -450,7 +452,8 @@ def start_trace(): recovery['app_effect'] = pointer_grounding.verify(after, pointer_grounding.read_pixels(after['proof_image']), oracle) prefix = trace.collect() save('recovery-trace-prefix.json', prefix) - recovery['trace'] = verify_recovery_trace(initial, prefix, capacity_lane(initial, prefix, 'click'), 'click') + tool = recovery['action']['tool'] + recovery['trace'] = verify_recovery_trace(initial, prefix, capacity_lane(initial, prefix, tool), tool) close_owned(fresh) trace.exchange('TRACE_STOP') tracing = False diff --git a/libs/cua-driver/hyprland-plugin/tests/production_session_fault_proof.py b/libs/cua-driver/hyprland-plugin/tests/production_session_fault_proof.py index 9cf380b916..362493ae9b 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production_session_fault_proof.py +++ b/libs/cua-driver/hyprland-plugin/tests/production_session_fault_proof.py @@ -68,7 +68,8 @@ def validate_plan(plan): geometry_plan({**plan, 'purpose': 'geometry_fault', 'compositor': {key: plan['compositor'][key] for key in ('pid', 'instance')}, 'fault': {'kind': 'move', 'to': [bounds['x'] + 1, bounds['y']]}}) - assert plan['agents'][0]['app'] == 'calc', 'only the qualified synthetic Calc episode is supported' + assert plan['agents'][0]['app'] == ('inkscape' if plan.get('app_profile') == 'inkscape-only' else 'calc'), \ + 'app requires its explicit qualification profile' assert set(plan['vm']) == {'machine_id', 'boot_id'} assert re.fullmatch(r'[0-9a-f]{32}', plan['vm']['machine_id']) assert re.fullmatch(r'[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}', plan['vm']['boot_id']) @@ -211,7 +212,7 @@ def check_targets(self): spec = self.plan['agents'][0] for identity in self.plan['identities'].values(): assert identity['uid'] == os.getuid() and _identity(identity['pid']) == identity, 'process identity changed' - app_process_identity('calc', spec['target']['pid']) + app_process_identity(spec['app'], spec['target']['pid']) fixture = self.args.source / 'libs/cua-driver/tests/fixtures/apps/linux/isolated-input/main.py' assert fixture.resolve(strict=True) == fixture, 'canonical source fixture required' assert hashlib.sha256(fixture.read_bytes()).hexdigest() == self.plan['foreground_fixture']['sha256'] @@ -229,7 +230,7 @@ def check_targets(self): assert len(selected) == 1 and selected[0].get('xwayland') is False assert int(selected[0]['address'], 16) == target['window_id'], 'target window changed' if target == spec['target']: - assert 'cua-smoke-calc' in selected[0].get('title', ''), 'wrong synthetic Calc document' + assert f'cua-smoke-{spec["app"]}' in selected[0].get('title', ''), 'wrong synthetic document' assert dict(zip(('x', 'y', 'width', 'height'), [*selected[0]['at'], *selected[0]['size']])) == spec['bounds'] def arm(self): @@ -364,16 +365,16 @@ def prepare_refusal(prepared, spec, stage): contention, not newer evidence. The refusal remains bounded by the first observation's original timestamp; powered-off capture is unavailable. """ - assert spec['app'] == 'calc' and stage in ('click_a1', 'click_b2') + assert stage in ({'click_a1', 'click_b2'} if spec['app'] == 'calc' else {'scroll_down', 'scroll_up'}) snapshot = prepared['snapshot'] assert prepared['target'] == spec['target'] assert {key: snapshot[key] for key in ('pid', 'window_id')} == spec['target'] assert snapshot['window_bounds'] == spec['bounds'] assert prepared['prepared_ns'] == snapshot['proof_observation_started_ns'] arguments, _ = pointer_grounding.action( - snapshot, pointer_grounding.read_pixels(snapshot['proof_image']), 'calc', stage) + snapshot, pointer_grounding.read_pixels(snapshot['proof_image']), spec['app'], stage) return {'snapshot': snapshot, 'arguments': arguments, 'session': spec['name'] + '-unavailable', - 'prepared_ns': prepared['prepared_ns']} + 'prepared_ns': prepared['prepared_ns'], 'tool': pointer_grounding.STAGES[spec['app']][stage]} def prepare_actions(clients, spec, stage, save): @@ -396,7 +397,7 @@ def refuse(client, spec, prepared, fault, trace, guard, save): fault.live_deadline() record['dispatch_ns'] = time.monotonic_ns() assert 0 <= record['dispatch_ns'] - record['prepared_ns'] <= MAX_GROUNDING_AGE_NS, 'refusal grounding expired' - record['response'] = client.tool('click', {**prepared['arguments'], **spec['target'], + record['response'] = client.tool(prepared.get('tool', 'click'), {**prepared['arguments'], **spec['target'], 'session': prepared['session'], 'delivery_mode': 'background'}) record['outcome'] = 'response' record.update(after=production_status(fault.config), monitors_after=fault.unavailable(), diff --git a/libs/cua-driver/hyprland-plugin/tests/production_session_fault_proof_test.py b/libs/cua-driver/hyprland-plugin/tests/production_session_fault_proof_test.py index ef7da16c52..a0c1ab1bef 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production_session_fault_proof_test.py +++ b/libs/cua-driver/hyprland-plugin/tests/production_session_fault_proof_test.py @@ -324,7 +324,7 @@ def test_refusal_keeps_original_observation_time(self): result = proof.prepare_refusal(prepared, spec, 'click_b2') self.assertEqual(result, {'snapshot': snapshot, 'arguments': {'x': 1, 'y': 2}, 'session': 'session-unavailable', - 'prepared_ns': observed_ns}) + 'prepared_ns': observed_ns, 'tool': 'click'}) def test_shared_observation_requires_exact_identity_geometry_and_time(self): spec = plan()['agents'][0] diff --git a/libs/cua-driver/hyprland-plugin/tests/production_target_lifetime_proof.py b/libs/cua-driver/hyprland-plugin/tests/production_target_lifetime_proof.py index 6d8ae30650..c03b91ad4a 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production_target_lifetime_proof.py +++ b/libs/cua-driver/hyprland-plugin/tests/production_target_lifetime_proof.py @@ -11,6 +11,11 @@ each profile is that document's sibling calc-profile. Both processes must have the suite's exact launch argv. The controller prepares both before this run. +Opt-in app_profile=inkscape-only uses move_rectangle for the victim and +click_rectangle on an unselected replacement. Each spec includes document, +app_id_tag and owned={document:{path,device,inode,uid,sha256}}. Distinct reviewed +tags bind exact /usr/bin/inkscape --app-id-tag= launch argv. + Only normal Driver MCP sends application input. This runner sends one SIGKILL through a revalidated pidfd, only after held-drag trace AND status gates. Saved bytes are archived before injection. A destroyed client cannot acknowledge a @@ -31,6 +36,7 @@ import os from pathlib import Path import platform +import re import select import signal import stat @@ -56,16 +62,21 @@ def validate_owned(spec): - assert spec['app'] == 'calc' and spec['drag'] == {} + assert spec['app'] in ('calc', 'inkscape') and spec['drag'] == {} owned = spec['owned'] - assert set(owned) == {'document', 'profile'} + assert set(owned) == ({'document'} if spec['app'] == 'inkscape' else {'document', 'profile'}) document = owned['document'] assert set(document) == {'path', 'device', 'inode', 'uid', 'sha256'} path = Path(document['path']) - assert path.is_absolute() and path.name == 'cua-smoke-calc.ods' + assert path.is_absolute() and path.name == ('cua-smoke-inkscape.svg' if spec['app'] == 'inkscape' else 'cua-smoke-calc.ods') assert all(type(document[k]) is int and document[k] >= 0 for k in ('device', 'inode', 'uid')) assert len(document['sha256']) == 64 and all(c in '0123456789abcdef' for c in document['sha256']) - assert Path(owned['profile']) == path.parent / 'calc-profile' + if spec['app'] == 'inkscape': + assert spec['document'] == str(path), 'owned SVG must be the reviewed target document' + assert isinstance(spec.get('app_id_tag'), str) and re.fullmatch(r'cua-profile-[A-Za-z0-9-]{1,64}', spec['app_id_tag']), \ + 'reviewed synthetic Inkscape app-id tag required' + else: + assert Path(owned['profile']) == path.parent / 'calc-profile' def validate_plan(plan): @@ -75,14 +86,16 @@ def validate_plan(plan): assert set(recovery) == {'mode', 'agent', 'identity'} assert recovery['mode'] == 'prepared_distinct_process' fresh = recovery['agent'] - assert fresh['pointer_stage'] in ('click_a1', 'click_b2') + inkscape = plan.get('app_profile') == 'inkscape-only' + assert fresh['pointer_stage'] in (('click_rectangle',) if inkscape else ('click_a1', 'click_b2')) + recovery_stage = 'scroll_down' if inkscape else 'click_b2' base = {k: v for k, v in plan.items() if k != 'fault'} primary_plan({**base, 'purpose': 'primary_conflict', 'case': 'initial_refusal', - 'recovery': {'pointer_stage': 'click_b2'}}) + 'recovery': {'pointer_stage': recovery_stage}}) primary_plan({**base, 'purpose': 'primary_conflict', 'case': 'initial_refusal', - 'agents': [{**fresh, 'pointer_stage': 'select_range'}], + 'agents': [{**fresh, 'pointer_stage': 'move_rectangle' if inkscape else 'select_range'}], 'processes': {**plan['processes'], 'target': recovery['identity']}, - 'recovery': {'pointer_stage': 'click_b2'}}) + 'recovery': {'pointer_stage': recovery_stage}}) old = plan['agents'][0] for spec in (old, fresh): validate_owned(spec) @@ -90,7 +103,11 @@ def validate_plan(plan): identities = [*plan['processes'].values(), recovery['identity'], plan['compositor']] assert len({p['pid'] for p in identities}) == 4, 'recovery must be a distinct process' assert len({s['window_id'] for s in (old['target'], fresh['target'], plan['foreground'])}) == 3 - assert old['name'] != fresh['name'] and old['owned']['profile'] != fresh['owned']['profile'] + assert old['name'] != fresh['name'] + if not inkscape: + assert old['owned']['profile'] != fresh['owned']['profile'] + else: + assert old['app_id_tag'] != fresh['app_id_tag'], 'distinct Inkscape application instances required' assert old['owned']['document']['path'] != fresh['owned']['document']['path'] @@ -129,6 +146,17 @@ def check_calc(spec, identity): assert [x.decode() for x in argv] == expected, 'not the exact dedicated suite Calc launch' +def check_app(spec, identity): + if spec['app'] == 'calc': + return check_calc(spec, identity) + assert spec['app'] == 'inkscape' and identity['exe'] == '/usr/bin/inkscape' + assert _identity(identity['pid']) == identity and identity['uid'] == os.getuid(), 'Inkscape process identity changed' + saved_document(spec) + argv = Path(f'/proc/{identity["pid"]}/cmdline').read_bytes().rstrip(b'\0').split(b'\0') + assert [word.decode() for word in argv] == [identity['exe'], '--app-id-tag=' + spec['app_id_tag'], spec['document']], \ + 'not the exact dedicated suite Inkscape launch' + + class TargetLifetime(ExactDesktop): """No PID-name matching, shell signals, fallback kills, relaunch or retries.""" def __init__(self, plan): @@ -140,8 +168,8 @@ def __init__(self, plan): assert subprocess.run(['systemd-detect-virt', '--vm', '--quiet'], timeout=2).returncode == 0 self.guard() for spec, identity in ((self.spec, plan['processes']['target']), (self.fresh, plan['recovery']['identity'])): - app_process_identity('calc', identity['pid']) - check_calc(spec, identity) + app_process_identity(spec['app'], identity['pid']) + check_app(spec, identity) saved_document(spec) self.fd = os.pidfd_open(self.spec['target']['pid']) try: @@ -170,8 +198,8 @@ def guard(self): window = selected[0] assert int(window['address'], 16) == target['window_id'] and window.get('xwayland') is False if spec: - check_calc(spec, identity) - assert 'cua-smoke-calc.ods' in window.get('title', '') + check_app(spec, identity) + assert Path(spec['owned']['document']['path']).name in window.get('title', '') assert window_bounds(window) == spec['bounds'], 'reviewed bounds changed' return windows @@ -385,9 +413,9 @@ def guard(): origin['files'][name] = {'path': str(path.resolve()), 'sha256': hashlib.sha256(path.read_bytes()).hexdigest()} save('provenance.json', origin) for name, spec in (('target', fault.spec), ('replacement', fault.fresh)): - (args.evidence / (name + '-saved-before.ods')).write_bytes(saved_document(spec)) + (args.evidence / (name + '-saved-before' + Path(spec['owned']['document']['path']).suffix)).write_bytes(saved_document(spec)) save('prepared-app-identities.json', { - name: app_process_identity('calc', spec['target']['pid']) + name: app_process_identity(spec['app'], spec['target']['pid']) for name, spec in (('target', fault.spec), ('replacement', fault.fresh))}) def launch(name): directory = args.evidence / name @@ -436,7 +464,7 @@ def launch(name): save('fault-prefix.json', boundary) report['fault'] = verify_fault(boundary, fault.record, action) for name, spec in (('target', fault.spec), ('replacement', fault.fresh)): - (args.evidence / (name + '-saved-after.ods')).write_bytes(saved_document(spec)) + (args.evidence / (name + '-saved-after' + Path(spec['owned']['document']['path']).suffix)).write_bytes(saved_document(spec)) report['saved_output'] = 'identity_and_bytes_unchanged; archived_before_and_after' close_owned(clients[0]) teardown = trace.collect() diff --git a/libs/cua-driver/hyprland-plugin/tests/production_target_lifetime_proof_test.py b/libs/cua-driver/hyprland-plugin/tests/production_target_lifetime_proof_test.py index a711e5b4e3..8d2c391bba 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production_target_lifetime_proof_test.py +++ b/libs/cua-driver/hyprland-plugin/tests/production_target_lifetime_proof_test.py @@ -59,6 +59,24 @@ def action(): class PlanTests(unittest.TestCase): + def test_inkscape_requires_exact_reviewed_app_id_tag_argv_and_identity(self): + spec = {'app': 'inkscape', 'app_id_tag': 'cua-profile-lane-0', + 'document': '/synthetic/cua-smoke-inkscape.svg'} + expected = {**identity(20), 'exe': '/usr/bin/inkscape', 'uid': os.getuid()} + argv = ['/usr/bin/inkscape', '--app-id-tag=cua-profile-lane-0', spec['document']] + for words in (argv, [argv[0], '--new-instance', argv[2]], + [argv[0], '--app-id-tag=cua-profile-lane-1', argv[2]], argv + ['/other.svg']): + with self.subTest(argv=words), patch.object(proof, '_identity', return_value=expected), \ + patch.object(proof, 'saved_document'), \ + patch.object(Path, 'read_bytes', return_value=b'\0'.join(x.encode() for x in words) + b'\0'): + if words == argv: + proof.check_app(spec, expected) + else: + with self.assertRaises(AssertionError): + proof.check_app(spec, expected) + with patch.object(proof, '_identity', return_value={}), self.assertRaises(AssertionError): + proof.check_app(spec, expected) + def test_exact_disposable_distinct_replacement_only(self): proof.validate_plan(plan()) mutations = [lambda p: p.update(disposable=False), From 67edb4fcd28612915e70a408b09164abb3ecc7c2 Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Wed, 9 Sep 2026 21:57:40 -0500 Subject: [PATCH 09/27] docs(cua-driver): explain Inkscape package qualification profile --- .../hyprland-plugin/tests/production-proof.md | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/libs/cua-driver/hyprland-plugin/tests/production-proof.md b/libs/cua-driver/hyprland-plugin/tests/production-proof.md index 09824848d4..9a96081963 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production-proof.md +++ b/libs/cua-driver/hyprland-plugin/tests/production-proof.md @@ -67,6 +67,40 @@ not native results. ## Reviewed native plans +### Inkscape-only environment profile + +Set `app_profile: "inkscape-only"` when qualifying a packaging environment +whose Inkscape package matches `1.4.4-6` but whose Calc package is not qualified. +The default Calc/Inkscape profile is unchanged. This test selection does not +widen Driver's production application allowlist or certify a package by itself. + +Use two distinct native Inkscape processes and separate synthetic SVG documents +for app proof, and a third process for capacity refusal. Bind each observed +process, native window, document, and geometry. A shared process with two +windows is not two independent clients. A verified launch form is +`/usr/bin/inkscape --app-id-tag=cua-profile-lane-0 /ABSOLUTE/cua-smoke-inkscape.svg`; +use a different tag and document for each client. + +The cancellation, geometry, primary-conflict, desktop, DPMS, lock, active-lock, +active-primary, idle-reconnect, and target-lifetime helpers accept this profile. +Fault targets use `pointer_stage: "move_rectangle"` and empty `drag` arguments +for fresh image grounding. Recovery sends a new `scroll_down` or `scroll_up` +action, never a replay of a partial or unknown drag. Active-lock/primary plans +declare both recovery directions; fresh visible-canvas grounding chooses one. +Idle plans omit target drag/stage fields and use fixed down/up scroll actions +on either side of the real peer expiry. The target-lifetime helper instead +requires an independently prepared, unselected replacement rectangle and a +`click_rectangle` recovery, with exact launch-argument and owned-file checks. + +Keep production and diagnostic evidence separate. Production runs require the +installed kit, profile, and build-provenance manifests and cannot use a trace +socket. Diagnostic runs require a trace socket and cannot borrow production +package provenance. The independent primary observer can check production +cursor/focus/grab isolation, but it does not prove compositor lane attribution, +overlapping delivery, or synthetic-seat cleanup. + +### Plan fields + Run only inside the prepared disposable desktop, after mapping one window per app and the independent foreground journal fixture. Ground the exact window identities, bounds, and gesture coordinates using fresh Driver snapshots. From 74beef6cb061a39432db4bd6b2d936458b97367f Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Wed, 9 Sep 2026 22:11:32 -0500 Subject: [PATCH 10/27] test(cua-driver): distinguish unchanged primary motion notifications --- .../hyprland-plugin/tests/primary_observer.py | 37 +++++++++-- .../hyprland-plugin/tests/production-proof.md | 12 ++++ .../tests/production_primary_observer_test.py | 66 +++++++++++++++++-- 3 files changed, 105 insertions(+), 10 deletions(-) diff --git a/libs/cua-driver/hyprland-plugin/tests/primary_observer.py b/libs/cua-driver/hyprland-plugin/tests/primary_observer.py index 5826ab59d6..a8f1415b62 100644 --- a/libs/cua-driver/hyprland-plugin/tests/primary_observer.py +++ b/libs/cua-driver/hyprland-plugin/tests/primary_observer.py @@ -1,4 +1,5 @@ """Independent parked-primary client evidence, never compositor attribution.""" +from decimal import Decimal import hashlib import json import math @@ -88,8 +89,17 @@ def journal_rows(data): def pointer_position(row): - values = row['arguments'].split(',') - assert len(values) == 3, 'malformed primary pointer motion' + values = [value.strip() for value in row['arguments'].split(',')] + assert len(values) == 3 and re.fullmatch(r'[0-9]+', values[0]) \ + and int(values[0]) <= 0xffffffff, 'malformed primary pointer motion' + assert all(re.fullmatch(r'-?[0-9]+(?:\.[0-9]+)?', value) for value in values[1:]), \ + 'malformed primary pointer coordinates' + # wl_fixed coordinates are signed 24.8 values. Validate before converting + # to float so rounding cannot disguise a differing or malformed position. + coordinates = [Decimal(value).as_integer_ratio() for value in values[1:]] + assert all(numerator * 256 % denominator == 0 + and -(1 << 31) * denominator <= numerator * 256 < (1 << 31) * denominator + for numerator, denominator in coordinates), 'invalid primary pointer fixed coordinates' point = [float(value) for value in values[1:]] assert all(math.isfinite(value) for value in point), 'nonfinite primary position' return point @@ -107,7 +117,7 @@ def primary_wire_state(data): if event == 'enter': assert len(values) == 4 and re.fullmatch(r'wl_surface[#@]\d+', values[1]) state.update(surface=int(re.split('[#@]', values[1])[1]), - position=[float(value) for value in values[2:]]) + position=pointer_position({'arguments': ','.join([values[0], *values[2:]])})) elif event == 'leave': state['surface'] = None elif event == 'motion': @@ -171,9 +181,14 @@ def analyze(before, after, journal, wire, intervals, primary_before, primary_aft primary = primary_wire_state(wire[:start['wire_end']]) violations = [] motions = [] + duplicate_motion_events = 0 + last_primary_position = primary['position'] if primary_before != primary_after: violations.append({'kind': 'primary_endpoints'}) for row in rows: + # GTK motion coordinates alone do not establish device identity or an + # independently captured baseline. Keep journal events and counters + # fail-closed even when the wire contains same-position duplicates. if row['kind'] in ('state', 'sync'): for key in ('clicks', 'keys', 'scroll', 'held', 'buttons', 'keys_down', 'window_active', 'canvas_focus', 'motion'): if row[key] != start[key]: @@ -186,7 +201,18 @@ def analyze(before, after, journal, wire, intervals, primary_before, primary_aft continue interface, event = row['interface'], row['event'] if interface == 'wl_pointer' and event == 'motion': - motions.append({'object': row['object'], 'position': pointer_position(row)}) + position = pointer_position(row) + motions.append({'object': row['object'], 'position': position}) + duplicate = (row['object'] == primary['pointer'] + and position == primary['position'] == last_primary_position) + if row['object'] == primary['pointer']: + last_primary_position = position + if duplicate: + # Retain every notification; only this exact primary-position + # duplicate is exempt. Any excursion still fails the interval, + # including an excursion followed by a return to this position. + duplicate_motion_events += 1 + continue forbidden = ((interface == 'wl_pointer' and event != 'frame') or interface == 'wl_keyboard' or interface == 'zwp_relative_pointer_v1' or interface == 'wl_touch' or interface == 'wl_seat' or (interface == 'wl_display' and event == 'error')) @@ -196,7 +222,8 @@ def analyze(before, after, journal, wire, intervals, primary_before, primary_aft 'scope': 'independent-parked-primary-client', 'compositor_attribution': False, 'primary': primary, 'start_ns': start['time'], 'end_ns': end['time'], 'action_intervals': intervals, 'journal_records': len(rows), 'wire_records': len(events), - 'complete': True, 'violations': violations, 'motions': motions} + 'complete': True, 'violations': violations, 'motions': motions, + 'duplicate_motion_events': duplicate_motion_events} def verify_negative_control(result): diff --git a/libs/cua-driver/hyprland-plugin/tests/production-proof.md b/libs/cua-driver/hyprland-plugin/tests/production-proof.md index 9a96081963..d2346ce630 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production-proof.md +++ b/libs/cua-driver/hyprland-plugin/tests/production-proof.md @@ -99,6 +99,18 @@ package provenance. The independent primary observer can check production cursor/focus/grab isolation, but it does not prove compositor lane attribution, overlapping delivery, or synthetic-seat cleanup. +For a parked primary, an exact same-position `wl_pointer.motion` notification +from the established primary pointer is not itself cursor theft. The observer +retains every motion and reports `duplicate_motion_events`. This exception +requires unchanged exact wire coordinates, primary identity, focus, held input, +and independent application state throughout the interval. Actual movement, +including an excursion and return, still fails. Foreign-pointer or relative +motion, focus/enter/leave changes, and button/key/scroll changes still fail. +The GTK journal does not independently identify a motion device and baseline, +so its motion events and counter changes remain failures even when the wire +contains duplicates. A passing duplicate-only case does not qualify the +negative control; that control must detect an actual excursion. + ### Plan fields Run only inside the prepared disposable desktop, after mapping one window per diff --git a/libs/cua-driver/hyprland-plugin/tests/production_primary_observer_test.py b/libs/cua-driver/hyprland-plugin/tests/production_primary_observer_test.py index 6ac8f38c94..24cb511a19 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production_primary_observer_test.py +++ b/libs/cua-driver/hyprland-plugin/tests/production_primary_observer_test.py @@ -32,6 +32,8 @@ def wire(*lines): 'wl_keyboard#6.modifiers(3, 0, 0, 0, 0)', 'wl_pointer#5.button(4, 100, 272, 1)') CANARY = wire('wl_pointer#5.motion(101, 340.00000000, 330.00000000)', 'wl_pointer#5.motion(102, 300.00000000, 300.00000000)') +DUPLICATES = wire('wl_pointer#5.motion(101, 300.00000000, 300.00000000)', + 'wl_pointer#5.motion(102, 300, 300.0)') def observation(events=b''): @@ -114,12 +116,64 @@ def test_warp_and_return_fails_identical_detector_even_if_gtk_coalesces_it(self) # Journal and endpoint counters intentionally remain identical. result = analyze(*values) self.assertEqual(result['result'], 'failed') + self.assertEqual(result['duplicate_motion_events'], 0) + self.assertEqual(len(result['violations']), 2) self.assertTrue(verify_negative_control(result)['verified']) self.assertEqual(result['motions'][-1]['position'], result['primary']['position']) with self.assertRaisesRegex(AssertionError, 'did not fail'): verify_negative_control(analyze(*observation())) - def test_focus_grab_keys_buttons_axis_and_any_motion_are_not_endpoint_evidence(self): + def test_exact_primary_duplicates_pass_and_are_retained_without_certifying_a_control(self): + result = analyze(*observation(DUPLICATES)) + self.assertEqual(result['result'], 'passed') + self.assertEqual(result['violations'], []) + self.assertEqual(result['duplicate_motion_events'], 2) + self.assertEqual(result['motions'], [{'object': 5, 'position': [300, 300]}] * 2) + self.assertEqual(result['wire_records'], len(wire_rows(DUPLICATES + SYNC))) + with self.assertRaisesRegex(AssertionError, 'did not fail'): + verify_negative_control(result) + + def test_duplicates_cannot_hide_excursions_foreign_pointers_or_endpoint_changes(self): + for events in (CANARY, wire('wl_pointer#5.motion(103, 300.00390625, 300)'), + wire('wl_pointer#5.motion(103, 300, 299.99609375)'), + DUPLICATES.replace(b'wl_pointer#5', b'wl_pointer#7')): + with self.subTest(events=events): + result = analyze(*observation(DUPLICATES + events + DUPLICATES)) + self.assertEqual(result['result'], 'failed') + self.assertEqual(len(result['motions']), 4 + len(wire_rows(events))) + self.assertTrue(result['violations']) + result = analyze(*observation(DUPLICATES + CANARY + DUPLICATES)) + self.assertTrue(verify_negative_control(result)['verified']) + values = observation(DUPLICATES) + values[-1]['cursor'] = [301, 300] + self.assertEqual(analyze(*values)['result'], 'failed') + + def test_malformed_motion_cannot_qualify_as_a_duplicate(self): + for arguments in ('bad, 300, 300', '-1, 300, 300', '4294967296, 300, 300', + '101, nan, 300', '101, inf, 300', '101, 3_00, 300', + '101, 300, 300, 0', '101, 300', '101, 300.00000000000000000000000000001, 300', + '101, 8388608, 300', '101, -8388608.00390625, 300'): + with self.subTest(arguments=arguments), self.assertRaises(AssertionError): + analyze(*observation(DUPLICATES + wire(f'wl_pointer#5.motion({arguments})'))) + + def test_journal_motion_and_counters_stay_fail_closed_even_with_matching_coordinates(self): + for coordinates in ({}, {'x': 300, 'y': 300}, {'x': 301, 'y': 300}): + values = observation(DUPLICATES) + values[2][2].update(kind='motion-notify', **coordinates) + result = analyze(*values) + self.assertEqual(result['result'], 'failed') + self.assertEqual(result['duplicate_motion_events'], 2) + for field, changed in (('motion', 1), ('clicks', 1), ('keys', 'a'), ('scroll', 1), + ('held', False), ('buttons', []), ('keys_down', [30]), + ('window_active', False), ('canvas_focus', False)): + with self.subTest(field=field): + values = observation(DUPLICATES) + values[2][2][field] = changed + result = analyze(*values) + self.assertEqual(result['result'], 'failed') + self.assertIn({'kind': 'journal_state', 'field': field, 'seq': 3}, result['violations']) + + def test_focus_grab_keys_buttons_axis_and_relative_motion_remain_forbidden_with_duplicates(self): events = ( 'wl_pointer#5.axis(101, 0, 10.0)', 'wl_pointer#5.axis_discrete(0, 1)', 'wl_pointer#5.axis_value120(0, 120)', 'wl_pointer#5.axis_source(0)', @@ -128,15 +182,17 @@ def test_focus_grab_keys_buttons_axis_and_any_motion_are_not_endpoint_evidence(s 'wl_keyboard#6.key(5, 101, 30, 1)', 'wl_keyboard#6.leave(5, wl_surface#10)', 'wl_keyboard#6.modifiers(5, 1, 0, 0, 0)', 'wl_seat#4.capabilities(0)', 'zwp_relative_pointer_v1#8.relative_motion(0, 100, 2.0, 0.0, 2.0, 0.0)', + 'zwp_relative_pointer_v1#8.relative_motion(0, 100, 0.0, 0.0, 0.0, 0.0)', + 'wl_pointer#5.unknown_event()', 'wl_keyboard#6.unknown_event()', ) for event in events: with self.subTest(event=event): - result = analyze(*observation(wire(event))) + result = analyze(*observation(DUPLICATES + wire(event) + DUPLICATES)) self.assertEqual(result['result'], 'failed') with self.assertRaises(AssertionError): verify_negative_control(result) - for kind in ('focus-change', 'grab-broken', 'leave-notify', 'button-release', 'key-release', 'scroll'): - values = observation() + for kind in ('focus-change', 'grab-broken', 'leave-notify', 'button-release', 'key-release', 'scroll', 'unknown-event'): + values = observation(DUPLICATES) values[2][2]['kind'] = kind self.assertEqual(analyze(*values)['result'], 'failed') @@ -163,7 +219,7 @@ def test_full_interval_fresh_heartbeats_and_sync_callbacks_are_mandatory(self): lambda v: v[0]['marker'].update(canvas_focus=False), lambda v: v[0]['marker'].update(wire_end=len(BASE)), ): - values = observation() + values = observation(DUPLICATES) mutation(values) with self.assertRaises(AssertionError): analyze(*values) From 0d44bbd5267543bd5b30ad239d47e463c9e36f6e Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Wed, 9 Sep 2026 22:22:54 -0500 Subject: [PATCH 11/27] test(cua-driver): reject recycled primary observer identities --- libs/cua-driver/hyprland-plugin/tests/primary_observer.py | 4 +++- .../hyprland-plugin/tests/production_primary_observer_test.py | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/libs/cua-driver/hyprland-plugin/tests/primary_observer.py b/libs/cua-driver/hyprland-plugin/tests/primary_observer.py index a8f1415b62..4a67d65dce 100644 --- a/libs/cua-driver/hyprland-plugin/tests/primary_observer.py +++ b/libs/cua-driver/hyprland-plugin/tests/primary_observer.py @@ -215,7 +215,9 @@ def analyze(before, after, journal, wire, intervals, primary_before, primary_aft continue forbidden = ((interface == 'wl_pointer' and event != 'frame') or interface == 'wl_keyboard' or interface == 'zwp_relative_pointer_v1' or interface == 'wl_touch' - or interface == 'wl_seat' or (interface == 'wl_display' and event == 'error')) + or interface == 'wl_seat' or (interface == 'wl_display' and event == 'error') + or (interface == 'wl_display' and event == 'delete_id' + and int(row['arguments']) in (primary['pointer'], primary['keyboard'], primary['surface']))) if forbidden: violations.append({'kind': 'wire_event', 'interface': interface, 'event': event, 'object': row['object']}) return {'result': 'failed' if violations else 'passed', diff --git a/libs/cua-driver/hyprland-plugin/tests/production_primary_observer_test.py b/libs/cua-driver/hyprland-plugin/tests/production_primary_observer_test.py index 24cb511a19..86b9fb35aa 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production_primary_observer_test.py +++ b/libs/cua-driver/hyprland-plugin/tests/production_primary_observer_test.py @@ -184,6 +184,7 @@ def test_focus_grab_keys_buttons_axis_and_relative_motion_remain_forbidden_with_ 'zwp_relative_pointer_v1#8.relative_motion(0, 100, 2.0, 0.0, 2.0, 0.0)', 'zwp_relative_pointer_v1#8.relative_motion(0, 100, 0.0, 0.0, 0.0, 0.0)', 'wl_pointer#5.unknown_event()', 'wl_keyboard#6.unknown_event()', + 'wl_display#1.delete_id(5)', 'wl_display#1.delete_id(6)', 'wl_display#1.delete_id(10)', ) for event in events: with self.subTest(event=event): From 59919bb6b1f21402969f3377272e100741e48a32 Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Wed, 9 Sep 2026 22:22:54 -0500 Subject: [PATCH 12/27] build(cua-driver): export pinned profile download recipes --- .../release/profile_download_recipe.py | 204 ++++++++++++ .../release/test_profile_download_recipe.py | 297 ++++++++++++++++++ 2 files changed, 501 insertions(+) create mode 100644 libs/cua-driver/hyprland-plugin/packaging/release/profile_download_recipe.py create mode 100644 libs/cua-driver/hyprland-plugin/packaging/release/test_profile_download_recipe.py diff --git a/libs/cua-driver/hyprland-plugin/packaging/release/profile_download_recipe.py b/libs/cua-driver/hyprland-plugin/packaging/release/profile_download_recipe.py new file mode 100644 index 0000000000..0b47cc9097 --- /dev/null +++ b/libs/cua-driver/hyprland-plugin/packaging/release/profile_download_recipe.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +"""Export a reviewed, checksum-pinned downstream PKGBUILD for an existing kit. + +The output is a separately reviewed wrapper, not the byte-identical inner recipe. +This performs no download, publication, native certification, or signing. +""" + +import argparse +import io +from pathlib import Path +import re +import tarfile +import tempfile + +import profile_verify as verify + +HERE = Path(__file__).resolve().parent +INVENTORY = set(verify.TOOLING) | { + "PROFILE.json", "KIT-PROVENANCE.json", "SOURCE-PROVENANCE.json", + "PKGBUILD", "SHA256SUMS", verify.STEM + ".tar.gz", +} + + +def archive_payload(data): + """Read the flat kit without extracting or importing anything from it.""" + payload = {} + with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as archive: + for member in archive: + verify.require(member.isfile() and not member.issparse() and not member.pax_headers, + "outer kit contains a nonregular or extended member") + verify.require(member.name in INVENTORY, "unsafe or unexpected outer kit path") + verify.require(member.name not in payload, "duplicate outer kit member") + payload[member.name] = archive.extractfile(member).read() + verify.require(set(payload) == INVENTORY, "outer kit inventory mismatch") + expected_sums = "".join(f"{verify.sha256(data)} {name}\n" for name, data in sorted(payload.items()) + if name != "SHA256SUMS").encode() + verify.require(payload["SHA256SUMS"] == expected_sums, "outer kit SHA256SUMS mismatch") + return payload + + +def reviewed_kit(archive, expected_sha): + verify.hash_value(expected_sha) + verify.require(archive.is_file() and not archive.is_symlink(), "outer archive must be a regular file") + data = archive.read_bytes() + verify.require(verify.sha256(data) == expected_sha, "outer archive checksum mismatch") + payload = archive_payload(data) + # Only local reviewed code executes. All scripts/templates must match this checkout. + for name in verify.TOOLING: + verify.require(payload[name] == (HERE / name).read_bytes(), f"kit differs from local reviewed tooling: {name}") + with tempfile.TemporaryDirectory(prefix="cua-profile-download-") as temporary: + kit = Path(temporary) + for name, content in payload.items(): + (kit / name).write_bytes(content) + profile, provenance = verify.verify_kit(kit, verify.sha256(payload["KIT-PROVENANCE.json"]), complete=True) + verify.require(payload["KIT-PROVENANCE.json"] == verify.json_bytes(provenance), + "kit provenance must match the recipe's canonical checksum") + verify.source_manifest(payload["SOURCE-PROVENANCE.json"], profile) + verify.verify_archive(kit / (verify.STEM + ".tar.gz"), profile) + with tarfile.open(fileobj=io.BytesIO(payload[verify.STEM + ".tar.gz"]), mode="r:gz") as source: + verify.require(all(member.isfile() and not member.issparse() and not member.pax_headers for member in source), + "source archive contains a nonregular or extended member") + expected_name = (f"{verify.STEM}-profile-{profile['profile_id']}-kit-{profile['kit_version']}" + f"-{provenance['profile_sha256']}-{provenance['tooling_revision']}.tar.gz") + verify.require(archive.name == expected_name, "outer archive filename does not match kit identity") + return payload, profile, provenance + + +def replace_once(text, old, new): + verify.require(text.count(old) == 1, "reviewed recipe adaptation anchor changed or duplicated") + return text.replace(old, new, 1) + + +# This wrapper-owned code runs before any downloaded Python is executed. Its +# pinned inventory also protects the extracted kit in --noextract/repackage runs. +# Read each archive into memory once, so validation and extraction use the same bytes. +DOWNLOAD_CHECK = r''' +_verify_download() { + python3 -I - "$SRCDEST/$_download_name" "$_download_sha256" "$srcdir" "$1" <<'CUA_DOWNLOAD_PY' +import hashlib +import io +from pathlib import Path, PurePosixPath +import sys +import tarfile + +expected = @MEMBER_HASHES@ +stem = '@STEM@' + +def require(condition, message): + if not condition: + raise SystemExit(message) + +def digest(data): + return hashlib.sha256(data).hexdigest() + +archive, checksum, srcdir, mode = sys.argv[1:] +archive, srcdir = Path(archive), Path(srcdir) +require(mode in {'check', 'extract'}, 'invalid kit verification mode') +require(archive.is_file() and not archive.is_symlink(), 'outer archive must be a regular file') +data = archive.read_bytes() +require(digest(data) == checksum, 'outer archive checksum mismatch') +payload = {} +with tarfile.open(fileobj=io.BytesIO(data), mode='r:gz') as contents: + for member in contents: + require(member.isfile() and not member.issparse() and not member.pax_headers, + 'nonregular outer kit member') + require(member.name in expected and member.name not in payload, 'outer kit inventory mismatch') + content = contents.extractfile(member).read() + require(digest(content) == expected[member.name], 'outer kit member checksum mismatch') + payload[member.name] = content +require(payload.keys() == expected.keys(), 'outer kit inventory mismatch') +require(srcdir.is_dir() and not srcdir.is_symlink(), 'srcdir must be a real directory') +kit, source = srcdir / 'cua-profile-kit', srcdir / stem +if mode == 'extract': + require(not kit.exists() and not kit.is_symlink() and not source.exists() and not source.is_symlink(), + 'prepare requires fresh kit and source destinations; use a clean srcdir') + source_payload = {} + with tarfile.open(fileobj=io.BytesIO(payload[stem + '.tar.gz']), mode='r:gz') as contents: + for member in contents: + require(member.isfile() and not member.issparse() and not member.pax_headers and + member.name.startswith(stem + '/'), 'invalid source member') + name = member.name[len(stem) + 1:] + path = PurePosixPath(name) + require(name and path.as_posix() == name and not path.is_absolute() and + '..' not in path.parts and '\\' not in name and name not in source_payload, + 'unsafe or duplicate source path') + source_payload[name] = contents.extractfile(member).read() + kit.mkdir() + source.mkdir() + for name, content in payload.items(): + (kit / name).write_bytes(content) + for name, content in source_payload.items(): + destination = source / name + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(content) +require(kit.is_dir() and not kit.is_symlink(), 'kit must be a real directory') +require({path.name for path in kit.iterdir()} == expected.keys(), 'extracted kit inventory mismatch') +for name, checksum in expected.items(): + path = kit / name + require(path.is_file() and not path.is_symlink() and digest(path.read_bytes()) == checksum, + 'extracted kit checksum mismatch: ' + name) +CUA_DOWNLOAD_PY +} +''' + + +def adapt_recipe(payload, profile, provenance, archive_name, expected_sha, download_url): + original = payload["PKGBUILD"].decode() + recipe = original + # Full rendering was verified above; refuse template drift even in reviewed tooling. + verify.require(recipe.count("$startdir") == 8, "reviewed recipe startdir references changed") + recipe = recipe.replace("$startdir", "$srcdir/cua-profile-kit") + source = ('source=("${_stem}.tar.gz" \'KIT-PROVENANCE.json\' \'PROFILE.json\' \'profile_verify.py\')\n' + f"sha256sums=('{profile['source']['archive_sha256']}' '{verify.sha256(verify.json_bytes(provenance))}' " + f"'{provenance['profile_sha256']}' '{provenance['tooling_files']['profile_verify.py']}')") + replacement = (f"_download_name='{archive_name}'\n_download_sha256='{expected_sha}'\n" + f"source=('{download_url}')\nnoextract=(\"$_download_name\")\nsha256sums=('{expected_sha}')") + recipe = replace_once(recipe, source, replacement) + recipe = replace_once(recipe, "_verify() {\n", "_verify() {\n" + " printf '%s %s\\n' \"$_download_sha256\" \"$SRCDEST/$_download_name\" | sha256sum -c - || return 1\n" + " _verify_download check || return 1\n") + recipe = replace_once(recipe, "prepare() {\n _verify\n}", + "prepare() {\n _verify_download extract || return 1\n _verify\n}") + runtime = DOWNLOAD_CHECK.replace("@MEMBER_HASHES@", repr({name: verify.sha256(data) for name, data in sorted(payload.items())})) + runtime = runtime.replace("@STEM@", verify.STEM) + recipe = replace_once(recipe, "\n_verify() {\n", runtime + "\n_verify() {\n") + verify.require("$startdir" not in recipe, "unadapted startdir reference") + # build/check/package retain every original instruction, with only kit paths moved. + verify.require(original.count("build() {\n") == recipe.count("build() {\n") == 1, + "reviewed build function anchor changed or duplicated") + verify.require(recipe[recipe.index("build() {\n"):] == + original[original.index("build() {\n"):].replace("$startdir", "$srcdir/cua-profile-kit"), + "build/check/package changed during adaptation") + return ("# Separately reviewed download wrapper; original kit and source identity are unchanged.\n" + "# Normal reruns need a fresh build directory; makepkg -e reuses verified extracted trees.\n" + + recipe).encode() + + +def generate(archive, expected_sha, download_url, output): + verify.require(isinstance(download_url, str) and re.fullmatch( + r"https://github\.com/trycua/cua/releases/download/[A-Za-z0-9][A-Za-z0-9._-]*/" + re.escape(archive.name), + download_url), "requires exact trycua/cua release URL, safe tag, and archive filename") + payload, profile, provenance = reviewed_kit(archive, expected_sha) + recipe = adapt_recipe(payload, profile, provenance, archive.name, expected_sha, download_url) + # Never truncate an existing recipe, including a symlink target. + with output.open("xb") as destination: + destination.write(recipe) + return recipe + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--archive", required=True, type=Path) + parser.add_argument("--sha256", required=True, help="independently reviewed outer archive SHA-256") + parser.add_argument("--url", required=True, help="exact future GitHub release asset URL") + parser.add_argument("--output", required=True, type=Path, help="new downstream PKGBUILD file") + args = parser.parse_args() + try: + generate(args.archive, args.sha256, args.url, args.output) + except (ValueError, KeyError, TypeError, OSError, tarfile.TarError) as error: + parser.exit(1, f"error: {error}\n") + + +if __name__ == "__main__": + main() diff --git a/libs/cua-driver/hyprland-plugin/packaging/release/test_profile_download_recipe.py b/libs/cua-driver/hyprland-plugin/packaging/release/test_profile_download_recipe.py new file mode 100644 index 0000000000..247d3f580f --- /dev/null +++ b/libs/cua-driver/hyprland-plugin/packaging/release/test_profile_download_recipe.py @@ -0,0 +1,297 @@ +"""Trust-boundary and shell contracts for the separate downstream download wrapper.""" + +import io +import shutil +import subprocess +import tarfile +import unittest + +import profile_bundle as bundle +import profile_download_recipe as download +import profile_verify as verify +import test_profile_release as fixtures + + +class DownloadRecipeTest(unittest.TestCase): + def setUp(self): + self.fixture = fixtures.ProfileTest() + self.fixture.setUp() + self.addCleanup(self.fixture.doCleanups) + self.root = self.fixture.root + output, self.kit, self.provenance = self.fixture.generate() + self.archive = next(output.glob("*.tar.gz")) + self.checksum = verify.digest(self.archive) + self.url = "https://github.com/trycua/cua/releases/download/profile-kit-v1/" + self.archive.name + self.output = self.root / "PKGBUILD" + self.payload = download.archive_payload(self.archive.read_bytes()) + self.srcdir = self.root / "makepkg src" + self.srcdir.mkdir() + self.startdir = self.root / "unrelated startdir" + self.startdir.mkdir() + + def generate(self): + return download.generate(self.archive, self.checksum, self.url, self.output) + + def rewrite(self, payload, *, sums=True): + if sums: + payload["SHA256SUMS"] = "".join(f"{verify.sha256(data)} {name}\n" for name, data in sorted(payload.items()) + if name != "SHA256SUMS").encode() + self.archive.write_bytes(bundle.deterministic_archive(payload)) + self.checksum = verify.digest(self.archive) + + def shell(self, command): + if not shutil.which("bash") or not shutil.which("sha256sum"): + self.skipTest("bash and sha256sum are required for generated recipe execution") + script = 'source "$1"; SRCDEST="$2"; srcdir="$3"; startdir="$4"; ' + command + return subprocess.run(["bash", "-c", script, "test", str(self.output), str(self.archive.parent), + str(self.srcdir), str(self.startdir)], capture_output=True, text=True) + + def extract(self): + self.generate() + result = self.shell("_verify_download extract") + self.assertEqual(result.returncode, 0, result.stderr) + return self.srcdir / "cua-profile-kit", self.srcdir / verify.STEM + + def test_deterministic_export_and_unchanged_kit(self): + original_archive = self.archive.read_bytes() + first = self.generate() + second = download.generate(self.archive, self.checksum, self.url, self.root / "PKGBUILD.second") + self.assertEqual(first, second) + self.assertEqual(self.archive.read_bytes(), original_archive) + self.assertNotEqual(first, self.payload["PKGBUILD"]) + self.assertIn(b"Separately reviewed download wrapper", first) + self.assertIn(f"source=('{self.url}')".encode(), first) + self.assertIn(f"sha256sums=('{self.checksum}')".encode(), first) + self.assertIn(b'noextract=("$_download_name")', first) + self.assertIn(b'python3 -I - "$SRCDEST/$_download_name"', first) + self.assertIn(b'"$SRCDEST/$_download_name" | sha256sum -c - || return 1', first) + self.assertNotIn(b"$startdir", first) + if shutil.which("bash"): + subprocess.run(["bash", "-n", str(self.output)], check=True, capture_output=True) + + def test_url_scope_and_shell_injection_refused(self): + base = self.url.rsplit("/", 2)[0] + bad_urls = [self.url.replace("https:", "http:"), self.url.replace("trycua/cua", "other/cua"), + self.url.replace("github.com", "github.com.evil.invalid"), self.url + "?download=1", + self.url + "#fragment", self.url + "\n", self.url.replace(self.archive.name, "other.tar.gz")] + bad_urls += [base + "/" + tag + "/" + self.archive.name + for tag in ("../escape", ".", "..", "%2e%2e", "$(touch injected)", "tag';false;'")] + for url in bad_urls: + with self.subTest(url=url), self.assertRaisesRegex(ValueError, "release URL"): + download.generate(self.archive, self.checksum, url, self.output) + self.assertFalse(self.output.exists()) + + def test_wrong_hash_and_archive_tamper_refused(self): + for checksum in ("f" * 64, "F" * 64, "abc", "a" * 64 + "\n"): + with self.subTest(checksum=checksum), self.assertRaises(ValueError): + download.generate(self.archive, checksum, self.url, self.output) + self.archive.write_bytes(self.archive.read_bytes() + b"tamper") + with self.assertRaisesRegex(ValueError, "outer archive checksum"): + self.generate() + self.assertFalse(self.output.exists()) + + def test_nonregular_or_renamed_archive_and_existing_output_refused(self): + linkdir = self.root / "links" + linkdir.mkdir() + link = linkdir / self.archive.name + link.symlink_to(self.archive) + with self.assertRaisesRegex(ValueError, "regular file"): + download.generate(link, self.checksum, self.url, self.output) + renamed = self.root / "renamed.tar.gz" + renamed.write_bytes(self.archive.read_bytes()) + with self.assertRaisesRegex(ValueError, "filename"): + download.generate(renamed, self.checksum, self.url.rsplit("/", 1)[0] + "/" + renamed.name, self.output) + self.output.write_bytes(b"existing work") + with self.assertRaises(FileExistsError): + self.generate() + self.assertEqual(self.output.read_bytes(), b"existing work") + output_link = self.root / "output-link" + output_link.symlink_to(self.output) + with self.assertRaises(FileExistsError): + download.generate(self.archive, self.checksum, self.url, output_link) + self.assertEqual(self.output.read_bytes(), b"existing work") + + def test_outer_inventory_paths_duplicates_and_links_refused(self): + for variant in ("symlink", "hardlink", "directory", "fifo", "duplicate", "traversal", "absolute", "backslash", "extra", "missing"): + raw = io.BytesIO() + with tarfile.open(fileobj=raw, mode="w:gz") as archive: + for name, data in self.payload.items(): + if variant == "missing" and name == "PROFILE.json": + continue + member = tarfile.TarInfo(name) + member.size = len(data) + archive.addfile(member, io.BytesIO(data)) + if variant != "missing": + name = {"duplicate": "PROFILE.json", "traversal": "../PROFILE.json", "absolute": "/PROFILE.json", + "backslash": "a\\PROFILE.json"}.get(variant, "extra") + member = tarfile.TarInfo(name) + member.type = {"symlink": tarfile.SYMTYPE, "hardlink": tarfile.LNKTYPE, + "directory": tarfile.DIRTYPE, "fifo": tarfile.FIFOTYPE}.get(variant, tarfile.REGTYPE) + member.linkname = "PROFILE.json" if variant in {"symlink", "hardlink"} else "" + archive.addfile(member) + self.archive.write_bytes(raw.getvalue()) + self.checksum = verify.digest(self.archive) + with self.subTest(variant=variant), self.assertRaises(ValueError): + self.generate() + + def test_internal_hashes_provenance_and_unreviewed_code_refused(self): + for name in ("SHA256SUMS", "PROFILE.json", "KIT-PROVENANCE.json", "SOURCE-PROVENANCE.json", + "profile_verify.py", "PKGBUILD", verify.STEM + ".tar.gz"): + candidate = dict(self.payload) + candidate[name] += b"\n# tampered\n" + self.rewrite(candidate, sums=name != "SHA256SUMS") + with self.subTest(name=name), self.assertRaises(ValueError): + self.generate() + candidate = dict(self.payload) + candidate["PROFILE-USAGE.md"] += b"tampered" + self.rewrite(candidate, sums=False) + with self.assertRaisesRegex(ValueError, "SHA256SUMS"): + self.generate() + + def test_source_inventory_validated_even_with_consistent_outer_hashes(self): + files = {verify.STEM + "/" + name: data for name, data in self.fixture.files.items()} + files[verify.STEM + "/extra"] = b"unexpected source" + candidate = dict(self.payload) + candidate[verify.STEM + ".tar.gz"] = bundle.deterministic_archive(files) + profile = verify.read_json(candidate["PROFILE.json"]) + profile["source"]["archive_sha256"] = verify.sha256(candidate[verify.STEM + ".tar.gz"]) + candidate["PROFILE.json"] = verify.json_bytes(profile) + provenance = verify.read_json(candidate["KIT-PROVENANCE.json"]) + provenance["source"] = profile["source"] + provenance["profile_sha256"] = verify.sha256(candidate["PROFILE.json"]) + candidate["KIT-PROVENANCE.json"] = verify.json_bytes(provenance) + candidate["PKGBUILD"] = verify.render_recipe(candidate["PROFILE-PKGBUILD.in"].decode(), profile, provenance) + self.rewrite(candidate) + with self.assertRaisesRegex(ValueError, "source archive inventory"): + self.generate() + + def test_source_extended_metadata_refused_before_export(self): + raw = io.BytesIO() + with tarfile.open(fileobj=raw, mode="w:gz", format=tarfile.PAX_FORMAT) as archive: + for name, data in self.fixture.files.items(): + member = tarfile.TarInfo(verify.STEM + "/" + name) + member.size = len(data) + member.pax_headers = {"comment": "unreviewed metadata"} + archive.addfile(member, io.BytesIO(data)) + candidate = dict(self.payload) + candidate[verify.STEM + ".tar.gz"] = raw.getvalue() + profile = verify.read_json(candidate["PROFILE.json"]) + profile["source"]["archive_sha256"] = verify.sha256(raw.getvalue()) + candidate["PROFILE.json"] = verify.json_bytes(profile) + provenance = verify.read_json(candidate["KIT-PROVENANCE.json"]) + provenance["source"] = profile["source"] + provenance["profile_sha256"] = verify.sha256(candidate["PROFILE.json"]) + candidate["KIT-PROVENANCE.json"] = verify.json_bytes(provenance) + candidate["PKGBUILD"] = verify.render_recipe(candidate["PROFILE-PKGBUILD.in"].decode(), profile, provenance) + self.rewrite(candidate) + with self.assertRaisesRegex(ValueError, "extended member"): + self.generate() + + def test_provenance_bytes_must_match_inner_recipe_checksum(self): + candidate = dict(self.payload) + candidate["KIT-PROVENANCE.json"] += b"\n" + self.rewrite(candidate) + with self.assertRaisesRegex(ValueError, "canonical checksum"): + self.generate() + + def test_prepare_rejects_tampered_outer_before_any_extraction(self): + self.generate() + self.archive.write_bytes(b"untrusted archive") + result = self.shell("SKIPINTEG=1; prepare") + self.assertNotEqual(result.returncode, 0) + self.assertIn("outer archive checksum", result.stderr) + self.assertEqual(list(self.srcdir.iterdir()), []) + + def test_adaptation_refuses_missing_or_duplicated_anchors(self): + for old, new in ((b"prepare() {\n _verify\n}", b"prepare() {\n true\n}"), + (b"prepare() {\n _verify\n}", b"prepare() {\n _verify\n}\nprepare() {\n _verify\n}"), + (b"$startdir/$name", b"$other/$name")): + candidate = dict(self.payload) + candidate["PKGBUILD"] = candidate["PKGBUILD"].replace(old, new) + with self.subTest(old=old), self.assertRaisesRegex(ValueError, "anchor|references"): + download.adapt_recipe(candidate, self.fixture.profile, self.provenance, self.archive.name, self.checksum, self.url) + + def test_prepare_extracts_exact_kit_and_source_with_distinct_srcdest(self): + kit, source = self.extract() + self.assertEqual({p.name: p.read_bytes() for p in kit.iterdir()}, self.payload) + self.assertEqual(verify.verify_source(source, self.fixture.profile), self.fixture.manifest) + result = self.shell('_verify_download check && [[ "$startdir" == "$4" ]]') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(list(self.startdir.iterdir()), []) + result = self.shell("_verify_download extract") + self.assertNotEqual(result.returncode, 0) + self.assertIn("fresh kit and source", result.stderr) + + def test_wrapper_owned_check_ignores_pythonpath_import_shadowing(self): + self.generate() + shadow = self.root / "shadow imports" + shadow.mkdir() + (shadow / "hashlib.py").write_text("raise SystemExit('UNTRUSTED_IMPORT_EXECUTED')\n") + result = self.shell('export PYTHONPATH="$4/../shadow imports"; _verify_download extract') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertNotIn("UNTRUSTED_IMPORT_EXECUTED", result.stderr) + + def test_prepare_refuses_existing_symlink_destinations_before_writes(self): + self.generate() + for name in ("cua-profile-kit", verify.STEM): + link = self.srcdir / name + link.symlink_to(self.startdir, target_is_directory=True) + result = self.shell("prepare") + self.assertNotEqual(result.returncode, 0) + self.assertIn("fresh kit and source", result.stderr) + self.assertEqual(list(self.startdir.iterdir()), []) + link.unlink() + + def test_every_phase_refuses_tampered_outer_kit_and_source(self): + kit, source = self.extract() + targets = [self.archive, kit / "PROFILE.json", kit / "profile_verify.py", kit / "KIT-PROVENANCE.json", + kit / (verify.STEM + ".tar.gz"), kit / "PKGBUILD", source / "src/plugin.cpp"] + for target in targets: + original = target.read_bytes() + target.write_bytes(b"raise SystemExit('UNTRUSTED_CODE_EXECUTED')\n") + for phase in ("_verify", "build", "check", "package"): + with self.subTest(target=target.name, phase=phase): + result = self.shell(phase) + self.assertNotEqual(result.returncode, 0) + self.assertNotIn("UNTRUSTED_CODE_EXECUTED", result.stderr) + self.assertIn("checksum", result.stderr) + target.write_bytes(original) + + def test_extracted_kit_and_source_symlinks_and_extra_files_refused(self): + kit, source = self.extract() + for target in (kit / "profile_verify.py", source / "src/plugin.cpp"): + original = target.read_bytes() + reference = self.root / "reference" + reference.write_bytes(original) + target.unlink() + target.symlink_to(reference) + result = self.shell("_verify") + self.assertNotEqual(result.returncode, 0) + target.unlink() + target.write_bytes(original) + for directory in (kit, source): + extra = directory / "extra" + extra.write_bytes(b"extra") + result = self.shell("_verify") + self.assertNotEqual(result.returncode, 0) + self.assertIn("inventory", result.stderr) + extra.unlink() + + def test_build_check_package_are_preserved_and_ctest_failure_blocks_package(self): + recipe = self.generate().decode() + original = self.payload["PKGBUILD"].decode() + self.assertEqual(recipe[recipe.index("build() {\n"):], + original[original.index("build() {\n"):].replace("$startdir", "$srcdir/cua-profile-kit")) + for required in ("--no-tests=error", "-DBUILD_TESTING=ON", "-DCUA_HYPRLAND_INPUT=ON", + "-DCUA_HYPRLAND_TEST_INPUT=OFF", "-DCUA_HYPRLAND_INPUT_TRACE=OFF", + '_verify --build "$srcdir/build" --output "$srcdir/BUILD-PROVENANCE.json"', + 'unset LD_PRELOAD FAKEROOTKEY FAKED_MODE'): + self.assertIn(required, recipe) + result = self.shell('_verify() { return 0; }; ctest() { return 17; }; ' + 'install() { echo UNEXPECTED_INSTALL >&2; return 0; }; package') + self.assertEqual(result.returncode, 1) + self.assertNotIn("UNEXPECTED_INSTALL", result.stderr) + + +if __name__ == "__main__": + unittest.main() From 6bfab642e5a0779a8e32fd72d6ef3efe8ea612ef Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Thu, 10 Sep 2026 00:57:44 -0500 Subject: [PATCH 13/27] test(cua-driver): retain Inkscape status in bounded fault snapshots --- .../hyprland-plugin/tests/production_cancel_proof.py | 12 ++++++------ .../tests/production_cancel_proof_test.py | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/libs/cua-driver/hyprland-plugin/tests/production_cancel_proof.py b/libs/cua-driver/hyprland-plugin/tests/production_cancel_proof.py index 8b1e171a5b..0e9a868d49 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production_cancel_proof.py +++ b/libs/cua-driver/hyprland-plugin/tests/production_cancel_proof.py @@ -34,12 +34,12 @@ GROUNDING_DISPATCH_RESERVE_NS = 250_000_000 MAX_GROUNDING_ATTEMPTS = 2 # max_elements counts all visited AT-SPI nodes, not only emitted controls. -# The native 2,000-node run retained the object row but stopped 14 rendered -# tree lines before the selection status. Allow 500 more visited nodes while -# keeping the trailing menu tree bounded; native coverage must verify this. -# Keep depth uncapped (the object row is deeply nested). Missing oracle -# evidence still fails closed, as does the unchanged five-second age limit. -POINTER_SNAPSHOT_LIMITS = {'inkscape': {'max_elements': 2500}} +# On the qualified Omarchy profile, even a 2,500-node walk retained the object +# row but stopped before the selection status. A 3,000-node native observation +# recovered the status and geometry in about 1.2 seconds per independent app. +# Keep the walk bounded and depth uncapped (the object row is deeply nested). +# Missing oracle evidence and the unchanged five-second age limit fail closed. +POINTER_SNAPSHOT_LIMITS = {'inkscape': {'max_elements': 3000}} def validate_app_profile(plan, *, require_drag=True): diff --git a/libs/cua-driver/hyprland-plugin/tests/production_cancel_proof_test.py b/libs/cua-driver/hyprland-plugin/tests/production_cancel_proof_test.py index e1ec63cb00..5c3b787597 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production_cancel_proof_test.py +++ b/libs/cua-driver/hyprland-plugin/tests/production_cancel_proof_test.py @@ -636,7 +636,7 @@ def tool(name, arguments): self.assertEqual(result['proof_observation_finished_ns'], 300) self.assertEqual(result['proof_runtime'], {'pid': 101, 'directory': str(root.resolve())}) self.assertEqual(mcp.tool.call_args.args, ('get_window_state', { - **spec['target'], 'session': spec['name'], 'max_elements': 2500})) + **spec['target'], 'session': spec['name'], 'max_elements': 3000})) # A bounded walk is not permission to omit the existing oracle. with patch('production_cancel_proof.pointer_grounding.read_pixels', return_value='pixels'): with self.assertRaisesRegex(RuntimeError, 'snapshot has no semantic elements'): From 8fec3f6f16ccae1fb36c3a95a952178f74e7ddac Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Thu, 10 Sep 2026 01:34:34 -0500 Subject: [PATCH 14/27] test(cua-driver): qualify same-client agent conflict refusal --- .../hyprland-plugin/tests/production-proof.md | 22 +- .../tests/production_agent_conflict_proof.py | 398 +++++++++++++ .../production_agent_conflict_proof_test.py | 557 ++++++++++++++++++ 3 files changed, 976 insertions(+), 1 deletion(-) create mode 100644 libs/cua-driver/hyprland-plugin/tests/production_agent_conflict_proof.py create mode 100644 libs/cua-driver/hyprland-plugin/tests/production_agent_conflict_proof_test.py diff --git a/libs/cua-driver/hyprland-plugin/tests/production-proof.md b/libs/cua-driver/hyprland-plugin/tests/production-proof.md index d2346ce630..7ceb4ac827 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production-proof.md +++ b/libs/cua-driver/hyprland-plugin/tests/production-proof.md @@ -398,9 +398,29 @@ provenance, and repeat controls. Select these diagnostics for unresolved claims under the validation strategy above; the canonical desktop matrix remains required. No existing failed row is superseded by these helper tests. +### Same-client passive-hover conflict + +`production_agent_conflict_proof.py` checks the distinct `agent_target_busy` +refusal. Prepare the exact identity fields used by the primary-conflict plan, +with `purpose:"agent_conflict"`, `case:"passive_hover_refusal"`, and +`app_profile:"inkscape-only"`. Use one agent with `drag:{}` and +`pointer_stage:"scroll_down"` or `"scroll_up"`; `refused.pointer_stage` must +name the opposite direction. Set `recovery.pointer_stage` to `"scroll_visible"` +or a reviewed fixed direction. The same provenance, trace, primary-grab, plan, +and evidence arguments apply. + +One Driver runtime scrolls and retains passive pointer focus. A second runtime +must refuse input to that same window without dispatching events or disturbing +the first owner's focus. The runner closes the refused runtime, then the owner, +and verifies that a third runtime can use the original lane for a fresh action. +It checks the application effect and continuous primary isolation throughout. +This case does not prove active-lease conflicts, same-process sibling windows, +or recovery on the other lane. Portable orchestration tests are not native +certification; retain the exact native artifact and result separately. + Focused local verification: ```text cd libs/cua-driver/hyprland-plugin/tests -python3 -m unittest production_realapp_proof_test realapp_proof_test primary_trace_test +python3 -m unittest production_realapp_proof_test realapp_proof_test primary_trace_test production_agent_conflict_proof_test ``` diff --git a/libs/cua-driver/hyprland-plugin/tests/production_agent_conflict_proof.py b/libs/cua-driver/hyprland-plugin/tests/production_agent_conflict_proof.py new file mode 100644 index 0000000000..c11f9d5402 --- /dev/null +++ b/libs/cua-driver/hyprland-plugin/tests/production_agent_conflict_proof.py @@ -0,0 +1,398 @@ +"""Run one native v3 same-client passive-hover agent-conflict refusal cell. + +CLI: --plan --evidence --driver --plugin --source --source-sha --trace-socket +--primary-grab --foreground-journal, as for production_primary_conflict_proof.py. +Run with python3.11 or newer on the exact prepared disposable VM. Input is sent +through normal Driver calls; the independent fixture holds the primary grab. +No app launch, config edit, signing, policy installation, or action replay. + +Integration boundary: the primary-conflict plan shape with purpose=agent_conflict, +case=passive_hover_refusal, app_profile=inkscape-only, one Inkscape agent whose +pointer_stage is scroll_down|scroll_up (drag={}), refused={pointer_stage} set to +the opposite scroll, and recovery={pointer_stage} in scroll_visible|scroll_down| +scroll_up. vm, compositor, processes, foreground, primary_point and +package_versions are unchanged. The controller must leave the separate +foreground fixture ready for the independent 60-second primary grab, which this +runner holds for the whole cell with one continuous strict trace. + +Sequence, one normal Driver call per stage, distinct runtimes A, B and C: +A performs one freshly grounded scroll and keeps its passive hover +(reserved, pointer_focus, no lease). B claims the other lane and its TARGET on +the exact same window must be refused agent_target_busy with zero synthetic +events, unchanged geometry/rectangle and A's state retained. B closes first, +then A; A's orphan hover remains while its reservation clears. C must reacquire +the original owner lane for a new freshly grounded scroll with an application +effect. Recovery on the other lane fails closed. Active-lease conflicts, +same-process sibling-window claims and other-lane recovery are UNPROVEN here. +Portable tests are preparation only; native execution is a separate gate. +""" +import argparse +from production_app_smoke import add_provenance_arguments +import hashlib +import json +from pathlib import Path +import subprocess +import time + +from driver_input_live import state, wait_for +from primary_trace import analyze +from production_cancel_proof import (MAX_GROUNDING_AGE_NS, PROFILE, close_owned, grounded_snapshot, + validate_app_profile, verify_fresh_observation, verify_recovery_cleanup, verify_recovery_trace) +from production_mcp import DirectMCP, assert_distinct_runtimes, stop_process +import production_pointer_grounding as pointer_grounding +from production_primary_conflict_proof import ExactDesktop, clear_status, validate_plan as primary_plan +from production_realapp_proof import (PRIMARY_LIFETIME_MS, app_process_identity, assert_no_dispatch, + capacity_lane, check_response, primary_acknowledgement, provenance, require_primary_active, trace_interval) +from realapp_proof import cleanup_all, released_synthetic_input + +SCROLL_STAGES = ('scroll_down', 'scroll_up') +OPPOSITE = {'scroll_down': 'scroll_up', 'scroll_up': 'scroll_down'} +RECOVERY_STAGES = ('scroll_visible', *SCROLL_STAGES) +STAGES = ('owner', 'refused', 'recovery') +REFUSAL = {'kind': 'refused', 'reason': 'agent_target_busy'} + + +def validate_plan(plan): + assert plan['purpose'] == 'agent_conflict' and plan['case'] == 'passive_hover_refusal' + assert plan.get('app_profile') == 'inkscape-only', 'this cell qualifies the Inkscape-only profile' + assert 'fault' not in plan, 'active faults are not covered by this runner' + assert len(plan['agents']) == 1, 'one target window, one agent' + spec = plan['agents'][0] + assert spec['app'] == 'inkscape' and spec['drag'] == {} + assert spec['pointer_stage'] in SCROLL_STAGES, 'owner needs a fixed visible scroll' + assert set(plan['refused']) == {'pointer_stage'} + assert plan['refused']['pointer_stage'] == OPPOSITE[spec['pointer_stage']], 'refused scroll must be opposite' + assert set(plan['recovery']) == {'pointer_stage'} and plan['recovery']['pointer_stage'] in RECOVERY_STAGES + # Reuse the profile and exact vm/compositor/process identity contracts; + # substitute the stages they require, keeping the scroll stages validated above. + validate_app_profile({**plan, 'recovery': {'pointer_stage': 'scroll_down'}}, require_drag=False) + primary_plan({**plan, 'purpose': 'primary_conflict', 'case': 'initial_refusal', + 'agents': [{**spec, 'pointer_stage': 'move_rectangle'}], 'recovery': {'pointer_stage': 'scroll_down'}}) + + +def lane_rows(status): + rows = {row['lane']: row for row in status['input']['lanes']} + assert set(rows) == {0, 1} + return rows + + +def verify_owner_status(status, owner_lane, *, peer_reserved=False): + """A live owner keeps passive hover on its lane without any input authority.""" + clear_status(status) + assert owner_lane in (0, 1) + rows = lane_rows(status) + assert rows[owner_lane]['reserved'] is True and rows[owner_lane]['pointer_focus'] is True, 'owner hover lost' + other = rows[1 - owner_lane] + assert other['reserved'] is peer_reserved, 'unexpected peer reservation' + assert other['pointer_focus'] is False, 'peer lane gained pointer focus' + return status + + +def verify_refusal_status(status, response, owner_lane): + """B's CLAIM survives the TARGET refusal until EOF, but grants nothing.""" + check_response(response, REFUSAL) + lane = response['structuredContent'].get('lane') + assert type(lane) is int and lane == 1 - owner_lane, 'refusal did not come from the other lane' + return verify_owner_status(status, owner_lane, peer_reserved=True) + + +def verify_orphan_status(status, owner_lane): + """After the owner's EOF only its inert hover remains; nothing is reserved.""" + clear_status(status, unreserved=True, allow_passive=True) + rows = lane_rows(status) + assert rows[owner_lane]['pointer_focus'] is True, 'orphan hover was retired without a fresh TARGET' + assert rows[1 - owner_lane]['pointer_focus'] is False, 'other lane gained pointer focus' + return status + + +def verify_refusal(before, after, response, owner_lane): + check_response(response, REFUSAL) + lane = response['structuredContent'].get('lane') + assert type(lane) is int and lane == 1 - owner_lane, 'refusal did not come from the other lane' + # Rejects every synthetic row in the call interval, including the owner's + # pointer_leave: a TARGET refusal must not evict protected peer hover. + assert_no_dispatch(before, after) + + +def verify_no_effect(after, image, oracle): + """The refused scroll must leave the reviewed rectangle and document unchanged.""" + assert oracle['app'] == 'inkscape' and oracle['stage'] in SCROLL_STAGES + rectangle = pointer_grounding.blue_rectangle(after, image) + previous = oracle['rectangle'] + assert all(abs(rectangle[key] - previous[key]) <= 1 for key in ('x', 'y', 'w', 'h')), 'refused scroll moved canvas' + geometry = pointer_grounding.inkscape_geometry(after, allow_transform_center=True) + assert geometry == oracle['geometry'], 'refused scroll changed document geometry' + return {'verified': True, 'scope': 'fresh-snapshot-no-pointer-effect', 'rectangle': rectangle, 'geometry': geometry} + + +def verify_close_events(before, after, allowed): + """Runtime EOF may only tear down; it never sends or replays input.""" + events = trace_interval(before, after) + assert all(row[2] in allowed for row in events if row[5] in (1, 2)), 'runtime close changed synthetic state' + assert not any(row[2] == 'agent_admitted' for row in events), 'runtime close admitted an agent' + return {'synthetic_events': [row for row in events if row[5] in (1, 2)], 'allowed': sorted(allowed)} + + +def ground(actor, spec): + """Ground one scroll on the acting runtime's fresh image; resolve scroll_visible before input.""" + started_ns = time.monotonic_ns() + before = grounded_snapshot(actor, spec['target'], spec) + image = pointer_grounding.read_pixels(before['proof_image']) + stage = spec['pointer_stage'] + if stage == 'scroll_visible': + stage = pointer_grounding.visible_inkscape_scroll_stage(before, image) + assert stage in SCROLL_STAGES and pointer_grounding.STAGES[spec['app']][stage] == 'scroll' + arguments, oracle = pointer_grounding.action(before, image, spec['app'], stage) + return {'snapshot': before, 'arguments': arguments, 'oracle': oracle, 'stage': stage, + 'requested_stage': spec['pointer_stage'], + 'prepared_ns': before.get('proof_observation_started_ns', started_ns)} + + +def action(actor, observer, spec, stage, trace, guard, save, record, *, owner_lane=None): + """One normal Driver scroll call; always retain its raw after-observation.""" + assert stage in STAGES and (owner_lane is None) is (stage == 'owner') + prepared = ground(actor, spec) + arguments = {**prepared['arguments'], **spec['target'], 'session': spec['name'], 'delivery_mode': 'background'} + record.update(runtime_pid=actor.process.pid, grounding=prepared, tool='scroll', arguments=arguments, + expected='refused' if stage == 'refused' else 'dispatched', outcome='not_attempted', replayed=False) + save(stage + '-action.json', record) + record['trace_before'] = trace.collect() + # Launch, session start and grounding must not touch either synthetic lane. + assert_no_dispatch(record['boundary'], record['trace_before']) + guard() + assert_distinct_runtimes([actor, observer]) + record['dispatch_ns'] = time.monotonic_ns() + assert 0 <= record['dispatch_ns'] - prepared['prepared_ns'] <= MAX_GROUNDING_AGE_NS, 'stale grounding' + record['outcome'] = 'unknown' + try: + record['response'] = actor.tool('scroll', arguments) + record['outcome'] = 'response' + except Exception as error: + record['error'] = str(error) + finally: + record['observed_ns'] = time.monotonic_ns() + save(stage + '-action.json', record) + try: + record['after'] = grounded_snapshot(observer, spec['target'], spec, session=False) + except Exception as error: + record['observation_error'] = str(error) + raise + finally: + # A failed screenshot must not discard independent dispatch evidence. + try: + record['trace_after'] = trace.collect() + finally: + save(stage + '-action.json', record) + guard() + assert_distinct_runtimes([actor, observer]) + assert record['dispatch_ns'] <= record['observed_ns'], 'action returned before dispatch' + verify_fresh_observation(prepared['snapshot'], record['after'], observer, after_ns=record['observed_ns']) + assert record['outcome'] == 'response', 'delivery unknown; never replay' + image = pointer_grounding.read_pixels(record['after']['proof_image']) + if stage == 'refused': + verify_refusal(record['trace_before'], record['trace_after'], record['response'], owner_lane) + record['no_effect'] = verify_no_effect(record['after'], image, prepared['oracle']) + else: + check_response(record['response'], {'kind': 'dispatched'}) + record['app_effect'] = pointer_grounding.verify(record['after'], image, prepared['oracle']) + lane = capacity_lane(record['trace_before'], record['trace_after'], 'scroll') + if stage == 'recovery': + assert lane == owner_lane + 1, 'recovery did not reacquire the original owner lane' + reported = record['response']['structuredContent'].get('lane') + assert reported is None or reported == lane - 1, 'Driver diagnostic lane disagrees with trace lane' + record['trace_verification'] = verify_recovery_trace(record['trace_before'], record['trace_after'], lane, 'scroll') + record['lane'], record['status_lane'] = lane, lane - 1 + save(stage + '-action.json', record) + + +def run(args): + if not __debug__: + raise RuntimeError('assertions must be enabled') + args.evidence.mkdir(parents=True, exist_ok=False) + def save(name, value): + (args.evidence / name).write_text(json.dumps(value, indent=2) + '\n') + report = {'result': 'failed', 'scope': 'same-client-passive-hover-refusal-and-owner-lane-recovery', + 'active_lease_conflict': 'unproven', 'other_lane_recovery': 'unproven', + 'same_process_sibling_window': 'unproven', 'full_desktop_matrix': False, + 'physical_hardware': False, 'stages': {}} + clients, observer, desktop, trace, grab = [], None, None, None, None + tracing, primary, baseline, deadline, prefix = False, None, None, None, None + def guard(): + assert desktop.primary(plan['foreground']) == primary, 'primary cursor/focus/workspace changed' + current = state(args.foreground_journal) + assert all(current[key] == baseline[key] for key in ('clicks', 'keys', 'scroll', 'held')), 'foreground input changed' + require_primary_active(grab, deadline) + def boundary(name): + nonlocal prefix + page = trace.collect() + previous, prefix = prefix, page + save(name, page) + return previous, page + def finish_trace(): + nonlocal tracing + trace.exchange('TRACE_STOP') + tracing = False + stopped = trace.collect() + save('trace.json', stopped) + checked = analyze(stopped) + assert checked['result'] == 'passed' and released_synthetic_input(stopped), checked + assert [row[2] for row in stopped['events'] if row[2] in ('start', 'stop')] == ['start', 'stop'], \ + 'trace restarted within the cell' + report['isolation'] = verify_recovery_cleanup(prefix, stopped) if prefix else checked + guard() + report['final_status'] = desktop.status(unreserved=True, allow_passive=True) + def launch(name): + directory = args.evidence / name + directory.mkdir() + value = DirectMCP(args.driver, directory, PROFILE) + clients.append(value) + return value + def start(actor, spec): + assert not actor.tool('start_session', {'session': spec['name']}).get('isError') + try: + plan = json.loads(args.plan.read_text()) + save('plan.json', plan) + validate_plan(plan) + spec = plan['agents'][0] + desktop = ExactDesktop(plan) + app_process_identity(spec['app'], spec['target']['pid']) + origin = provenance(args, plan) + for name in (Path(__file__).name, 'production_agent_conflict_proof_test.py', + 'production_primary_conflict_proof.py', 'desktop_faults.py', 'production_cancel_proof.py', + 'production_desktop_fault_proof.py', 'production_geometry_fault_proof.py'): + path = Path(__file__).with_name(name) + origin['files'][name] = {'path': str(path.resolve()), 'sha256': hashlib.sha256(path.read_bytes()).hexdigest()} + origin['ownership'] = {'vm': plan['vm'], 'compositor': plan['compositor'], 'processes': plan['processes']} + save('provenance.json', origin) + observer = launch('observer') + trace = desktop.trace(args.trace_socket) + report['preflight_status'] = desktop.status(unreserved=True) + # The independent primary fixture holds the separate foreground client + # for the whole cell; no agent action may change it. + foreground = grounded_snapshot(observer, plan['foreground'])['window_bounds'] + result = observer.tool('get_desktop_state', {}) + assert not result.get('isError') + screen = result['structuredContent'] + x, y = plan['primary_point'] + assert 0 < x < foreground['width'] and 0 < y < foreground['height'] + x, y = foreground['x'] + x, foreground['y'] + y + assert 0 <= x < screen['screen_width'] and 0 <= y < screen['screen_height'] + desktop.guard() + deadline = time.monotonic_ns() + PRIMARY_LIFETIME_MS * 1_000_000 + grab = subprocess.Popen([str(args.primary_grab), str(x), str(y), str(screen['screen_width']), + str(screen['screen_height']), str(PRIMARY_LIFETIME_MS)], stdout=subprocess.PIPE, text=True) + assert primary_acknowledgement(grab.stdout) == 'HELD\n' + wait_for(lambda: state(args.foreground_journal)['held'], timeout=3) + primary, baseline = desktop.primary(plan['foreground']), state(args.foreground_journal) + assert primary['cursor'] == {'x': x, 'y': y}, 'primary fixture missed planned point' + assert baseline['held'] + report.update(primary_before=primary, foreground_before=baseline) + report['initial_status'] = desktop.status(unreserved=True) + started_ns = time.monotonic_ns() + # A lost acknowledgement can still mean tracing started; retain the + # cleanup obligation before issuing the test-only command. + tracing = True + trace.exchange('TRACE_START') + prefix = trace.collect() + trace_interval(prefix, prefix) + assert prefix['count'] == 1 and started_ns <= prefix['events'][0][1] <= time.monotonic_ns() + report['initial_trace'] = prefix + guard() + + owner = launch('owner') + assert_distinct_runtimes([owner, observer]) + start(owner, spec) + row = report['stages']['owner'] = {'boundary': prefix} + action(owner, observer, spec, 'owner', trace, guard, save, row) + prefix = row['trace_after'] + owner_lane = row['status_lane'] + row['post_action_status'] = desktop.raw_status() + save('owner-action.json', row) + verify_owner_status(row['post_action_status'], owner_lane) + row['result'] = 'verified' + save('owner-action.json', row) + + refused = launch('refused') + assert_distinct_runtimes([owner, refused, observer]) + refused_spec = {**spec, 'name': spec['name'] + '-refused', 'pointer_stage': plan['refused']['pointer_stage']} + start(refused, refused_spec) + row = report['stages']['refused'] = {'boundary': prefix} + action(refused, observer, refused_spec, 'refused', trace, guard, save, row, owner_lane=owner_lane) + prefix = row['trace_after'] + # Retain the observed status before validation can raise. A live + # refused connection still owns its CLAIM; EOF must clear it below. + row['post_action_status'] = desktop.raw_status() + save('refused-action.json', row) + verify_refusal_status(row['post_action_status'], row['response'], owner_lane) + assert_distinct_runtimes([owner, refused, observer]) + row['result'] = 'verified' + save('refused-action.json', row) + + close_owned(refused) + row = report['stages']['close_refused'] = {'runtime_pid': refused.process.pid} + row['status'] = wait_for(lambda: verify_owner_status(desktop.raw_status(), owner_lane), timeout=2) + row['teardown'] = verify_close_events(*boundary('close-refused-trace.json'), set()) + assert_distinct_runtimes([owner, observer]) + guard() + save('close-refused.json', row) + + close_owned(owner) + row = report['stages']['close_owner'] = {'runtime_pid': owner.process.pid} + row['status'] = wait_for(lambda: verify_orphan_status(desktop.raw_status(), owner_lane), timeout=2) + row['teardown'] = verify_close_events(*boundary('close-owner-trace.json'), {'agent_cancel'}) + guard() + save('close-owner.json', row) + + recovery = launch('recovery') + assert_distinct_runtimes([recovery, observer]) + assert len({owner.process.pid, refused.process.pid, recovery.process.pid, observer.process.pid}) == 4, \ + 'reused runtime process' + recovery_spec = {**spec, 'name': spec['name'] + '-recovery', 'pointer_stage': plan['recovery']['pointer_stage']} + start(recovery, recovery_spec) + row = report['stages']['recovery'] = {'boundary': prefix} + action(recovery, observer, recovery_spec, 'recovery', trace, guard, save, row, owner_lane=owner_lane) + prefix = row['trace_after'] + row['post_action_status'] = desktop.raw_status() + save('recovery-action.json', row) + verify_owner_status(row['post_action_status'], owner_lane) + row['result'] = 'verified' + save('recovery-action.json', row) + + close_owned(recovery) + row = report['stages']['close_recovery'] = {'runtime_pid': recovery.process.pid} + row['status'] = wait_for(lambda: desktop.status(unreserved=True, allow_passive=True), timeout=2) + save('close-recovery.json', row) + finish_trace() + report['result'] = 'passed' + except Exception as error: + report['error'] = {'type': type(error).__name__, 'message': str(error)} + finally: + operations = [(f'close_runtime_{i}', lambda c=c: close_owned(c)) for i, c in enumerate(clients) if c is not observer] + if trace: + if tracing: + operations.append(('finish_trace', finish_trace)) + operations.append(('close_trace', trace.close)) + def release_primary(): + if grab: + if grab.poll() is None: + grab.terminate() + stop_process(grab) + wait_for(lambda: not state(args.foreground_journal)['held'], timeout=3) + operations.append(('release_primary', release_primary)) + if observer: + operations.append(('close_observer', lambda: close_owned(observer))) + errors = cleanup_all(operations) + save('cleanup.json', {'errors': errors}) + if errors: + report['result'] = 'failed' + save('result.json', report) + print(json.dumps(report), flush=True) + return 0 if report['result'] == 'passed' else 1 + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + for name in ('driver', 'plugin', 'source', 'primary-grab', 'plan', 'evidence', 'foreground-journal', 'trace-socket'): + parser.add_argument('--' + name, required=True, type=Path) + add_provenance_arguments(parser) + raise SystemExit(run(parser.parse_args())) diff --git a/libs/cua-driver/hyprland-plugin/tests/production_agent_conflict_proof_test.py b/libs/cua-driver/hyprland-plugin/tests/production_agent_conflict_proof_test.py new file mode 100644 index 0000000000..2cace8c843 --- /dev/null +++ b/libs/cua-driver/hyprland-plugin/tests/production_agent_conflict_proof_test.py @@ -0,0 +1,557 @@ +"""Portable adversarial orchestration tests; no native desktop is certified.""" +from contextlib import ExitStack +from copy import deepcopy +import json +from pathlib import Path +import tempfile +from types import SimpleNamespace +import unittest +from unittest.mock import Mock, patch + +import production_agent_conflict_proof as proof + + +BOUNDS = {'x': 10, 'y': 20, 'width': 800, 'height': 600} +REFUSED = {'isError': True, 'structuredContent': {'effect': 'refused', 'reason': 'agent_target_busy', 'lane': 1}} +PRIMARY_REFUSED = {'isError': True, 'structuredContent': {'effect': 'refused', 'reason': 'primary_target_busy', 'lane': 1}} +DELIVERED = {'structuredContent': {'effect': 'unverifiable', 'route': 'synthetic_events', + 'delivery': {'mode': 'background'}}} +RECTANGLE = {'x': 100, 'y': 200, 'w': 60, 'h': 40, 'center': [129, 219]} +GEOMETRY = {'X': 1.0, 'Y': 2.0, 'W': 3.0, 'H': 4.0} +ORACLE = {'app': 'inkscape', 'stage': 'scroll_down', 'rectangle': RECTANGLE, 'geometry': GEOMETRY} + + +def identity(pid, name='app'): + return {'pid': pid, 'uid': 1000, 'starttime': '123', 'exe': '/usr/bin/' + name} + + +def plan(stage='scroll_down', recovery='scroll_visible'): + return {'purpose': 'agent_conflict', 'case': 'passive_hover_refusal', 'app_profile': 'inkscape-only', + 'disposable': True, + 'vm': {'machine_id': 'a' * 32, 'boot_id': 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'}, + 'compositor': {**identity(50, 'Hyprland'), 'instance': 'test_1'}, + 'processes': {'target': identity(20, 'inkscape'), 'foreground': identity(10)}, + 'foreground': {'pid': 10, 'window_id': 100}, 'primary_point': [20, 20], 'package_versions': {}, + 'agents': [{'app': 'inkscape', 'name': 'agent-conflict', 'target': {'pid': 20, 'window_id': 200}, + 'document': '/tmp/cua/cua-smoke-inkscape.svg', 'bounds': dict(BOUNDS), 'pointer_stage': stage, 'drag': {}}], + 'refused': {'pointer_stage': proof.OPPOSITE[stage]}, 'recovery': {'pointer_stage': recovery}} + + +def trace(events=(), active=True): + rows = [(0, 'start', 0, 0), *events] + if not active: + rows += [(20, 'stop', 0, 0)] + return {'hook': True, 'active': active, 'overflow': False, 'timed_out': False, 'count': len(rows), + 'events': [[i + 1, timestamp, kind, 30, 40, lane, value] + for i, (timestamp, kind, lane, value) in enumerate(rows)]} + + +def scroll_rows(lane, start=1): + return [(start, 'agent_admitted', lane, 0), (start + 1, 'pointer_axis', lane, 1), + (start + 2, 'agent_action_end', lane, 0)] + + +def status(): + return {'configured': True, 'transport': {'ready': True}, 'input': { + 'protocol': 3, 'test_only': False, 'transport_ready': True, + 'seat_lifetime': 'compositor', 'upgrade': 'desktop_restart', 'lanes': [ + {'lane': lane, 'held_button': 0, 'held_keys': 0, 'drag_active': False, + 'lease_active': False, 'keyboard_focus': False, 'pointer_focus': False, 'reserved': False} + for lane in (0, 1)]}} + + +def owner_status(owner_lane, *, peer_reserved=False, orphan=False): + value = status() + value['input']['lanes'][owner_lane].update(pointer_focus=True, reserved=not orphan) + value['input']['lanes'][1 - owner_lane]['reserved'] = peer_reserved + return value + + +def client(pid): + return Mock(directory=Path.cwd(), process=Mock(pid=pid, poll=Mock(return_value=None))) + + +class PlanTests(unittest.TestCase): + def test_plan_requires_exact_narrow_same_client_scroll_cell(self): + for stage in proof.SCROLL_STAGES: + for recovery in proof.RECOVERY_STAGES: + proof.validate_plan(plan(stage, recovery)) + updates = ({'purpose': 'primary_conflict'}, {'case': 'initial_refusal'}, {'app_profile': 'calc-inkscape'}, + {'disposable': False}, {'fault': {'kind': 'move', 'to': [11, 20]}}, + {'refused': {'pointer_stage': 'scroll_down'}}, {'refused': {'pointer_stage': 'scroll_visible'}}, + {'refused': {'pointer_stage': 'scroll_up', 'extra': 1}}, {'refused': {}}, + {'recovery': {'pointer_stage': 'move_rectangle'}}, {'recovery': {'pointer_stage': 'click_rectangle'}}, + {'vm': {'machine_id': 'unknown', 'boot_id': 'unknown'}}, + {'agents': [plan()['agents'][0], plan()['agents'][0]]}) + for update in updates: + with self.subTest(update=update), self.assertRaises(AssertionError): + proof.validate_plan({**plan(), **update}) + for key, value in (('pointer_stage', 'move_rectangle'), ('pointer_stage', 'click_rectangle'), + ('pointer_stage', 'scroll_visible'), ('drag', {'from_x': 1}), ('app', 'calc'), + ('document', '/tmp/other.svg'), ('document', 'cua-smoke-inkscape.svg')): + candidate = plan() + candidate['agents'][0][key] = value + with self.subTest(key=key, value=value), self.assertRaises((AssertionError, KeyError)): + proof.validate_plan(candidate) + missing = plan() + del missing['agents'][0]['document'] + with self.assertRaises((AssertionError, KeyError)): + proof.validate_plan(missing) + for owner, key, value in (('target', 'exe', '/usr/bin/calc'), ('target', 'pid', 21), ('target', 'uid', 1001), + ('foreground', 'starttime', ''), ('foreground', 'exe', 'relative')): + candidate = plan() + candidate['processes'][owner][key] = value + with self.subTest(owner=owner, key=key), self.assertRaises(AssertionError): + proof.validate_plan(candidate) + shared = plan() + shared['foreground']['window_id'] = 200 + with self.assertRaises(AssertionError): + proof.validate_plan(shared) + + +class StatusTests(unittest.TestCase): + def test_owner_refusal_and_orphan_invariants_reject_every_mutation(self): + for owner_lane in (0, 1): + live = owner_status(owner_lane) + self.assertIs(proof.verify_owner_status(live, owner_lane), live) + with self.assertRaises(AssertionError): + proof.verify_owner_status(live, 1 - owner_lane) + refusing = owner_status(owner_lane, peer_reserved=True) + response = {**REFUSED, 'structuredContent': {**REFUSED['structuredContent'], 'lane': 1 - owner_lane}} + self.assertIs(proof.verify_refusal_status(refusing, response, owner_lane), refusing) + with self.assertRaises(AssertionError): + proof.verify_owner_status(refusing, owner_lane) # peer reservation must clear after B's EOF + with self.assertRaises(AssertionError): + proof.verify_refusal_status(live, response, owner_lane) # refused CLAIM must still be reserved + for invalid in (owner_lane, None, True, -1, 2): + with self.subTest(invalid=invalid), self.assertRaises(AssertionError): + proof.verify_refusal_status(refusing, {**response, 'structuredContent': { + **response['structuredContent'], 'lane': invalid}}, owner_lane) + with self.assertRaises(AssertionError): + proof.verify_refusal_status(refusing, {**PRIMARY_REFUSED, 'structuredContent': { + **PRIMARY_REFUSED['structuredContent'], 'lane': 1 - owner_lane}}, owner_lane) + orphan = owner_status(owner_lane, orphan=True) + self.assertIs(proof.verify_orphan_status(orphan, owner_lane), orphan) + with self.assertRaises(AssertionError): + proof.verify_orphan_status(live, owner_lane) # reservation must clear + with self.assertRaises(AssertionError): + proof.verify_orphan_status(status(), owner_lane) # hover must remain until a fresh TARGET + with self.assertRaises(AssertionError): + proof.verify_owner_status(orphan, owner_lane) + mutations = ((owner_lane, 'pointer_focus', False), (1 - owner_lane, 'pointer_focus', True), + (owner_lane, 'lease_active', True), (1 - owner_lane, 'lease_active', True), + (owner_lane, 'drag_active', True), (owner_lane, 'keyboard_focus', True), + (owner_lane, 'held_button', 272), (owner_lane, 'held_keys', 1), + (1 - owner_lane, 'held_button', 272), (owner_lane, 'reserved', 1)) + for base, verify in ((live, lambda s: proof.verify_owner_status(s, owner_lane)), + (refusing, lambda s: proof.verify_refusal_status(s, response, owner_lane)), + (orphan, lambda s: proof.verify_orphan_status(s, owner_lane))): + for index, field, value in mutations: + candidate = deepcopy(base) + candidate['input']['lanes'][index][field] = value + with self.subTest(owner_lane=owner_lane, index=index, field=field), self.assertRaises(AssertionError): + verify(candidate) + for base in (live, refusing, orphan): + broken = deepcopy(base) + broken['input']['lanes'][1]['lane'] = 0 + with self.assertRaises(AssertionError): + proof.lane_rows(broken) + + +class OracleTests(unittest.TestCase): + def test_exact_refusal_from_other_lane_with_zero_synthetic_events(self): + before = trace(scroll_rows(1)) + proof.verify_refusal(before, before, REFUSED, 0) + for response in (DELIVERED, PRIMARY_REFUSED, + {**REFUSED, 'structuredContent': {**REFUSED['structuredContent'], 'lane': 0}}, + {**REFUSED, 'structuredContent': {**REFUSED['structuredContent'], 'lane': None}}, + {**REFUSED, 'structuredContent': {**REFUSED['structuredContent'], 'delivery': {'mode': 'unknown'}}}, + {'isError': True, 'structuredContent': {'effect': 'refused', 'reason': 'lane_busy', 'lane': 1}}): + with self.subTest(response=response), self.assertRaises(AssertionError): + proof.verify_refusal(before, before, response, 0) + for kind in ('agent_admitted', 'agent_cancel', 'pointer_enter', 'pointer_leave', 'pointer_button', + 'pointer_motion', 'pointer_axis', 'keyboard_key', 'agent_drag_start', 'agent_action_end'): + for lane in (1, 2): + after = trace(scroll_rows(1) + [(9, kind, lane, 0)]) + with self.subTest(kind=kind, lane=lane), self.assertRaises(AssertionError): + proof.verify_refusal(before, after, REFUSED, 0) + for key, value in (('hook', False), ('active', False), ('overflow', True), ('timed_out', True), ('count', 9)): + with self.subTest(key=key), self.assertRaises(AssertionError): + proof.verify_refusal(before, {**before, key: value}, REFUSED, 0) + changed = deepcopy(before) + changed['events'][1][1] += 1 + with self.assertRaisesRegex(AssertionError, 'history'): + proof.verify_refusal(before, changed, REFUSED, 0) + + def test_refused_scroll_must_leave_rectangle_and_document_unchanged(self): + after, image = {'proof_image': 'after.png'}, object() + for failure in (None, 'moved', 'resized', 'geometry', 'stage', 'app', 'unreadable'): + rectangle = dict(RECTANGLE) + if failure == 'moved': + rectangle['y'] -= 3 + elif failure == 'resized': + rectangle['w'] += 2 + oracle = deepcopy(ORACLE) + if failure == 'stage': + oracle['stage'] = 'move_rectangle' + elif failure == 'app': + oracle['app'] = 'calc' + with self.subTest(failure=failure), ExitStack() as stack: + blue = stack.enter_context(patch.object(proof.pointer_grounding, 'blue_rectangle', + side_effect=proof.pointer_grounding.GroundingUnavailable('hidden') if failure == 'unreadable' else None, + return_value=rectangle)) + geometry = stack.enter_context(patch.object(proof.pointer_grounding, 'inkscape_geometry', + return_value={**GEOMETRY, 'X': 1.5} if failure == 'geometry' else dict(GEOMETRY))) + if failure: + with self.assertRaises(Exception): + proof.verify_no_effect(after, image, oracle) + else: + result = proof.verify_no_effect(after, image, oracle) + self.assertTrue(result['verified']) + blue.assert_called_once_with(after, image) + geometry.assert_called_once_with(after, allow_transform_center=True) + within = {**RECTANGLE, 'x': RECTANGLE['x'] + 1} + with patch.object(proof.pointer_grounding, 'blue_rectangle', return_value=within), \ + patch.object(proof.pointer_grounding, 'inkscape_geometry', return_value=dict(GEOMETRY)): + proof.verify_no_effect(after, image, ORACLE) + + def test_runtime_close_may_only_tear_down(self): + before = trace(scroll_rows(1)) + self.assertEqual(proof.verify_close_events(before, before, set())['synthetic_events'], []) + cancel = trace(scroll_rows(1) + [(9, 'agent_cancel', 1, 0)]) + self.assertEqual(len(proof.verify_close_events(before, cancel, {'agent_cancel'})['synthetic_events']), 1) + with self.assertRaises(AssertionError): + proof.verify_close_events(before, cancel, set()) + for kind in ('pointer_leave', 'pointer_axis', 'pointer_button', 'agent_admitted', 'pointer_enter'): + with self.subTest(kind=kind), self.assertRaises(AssertionError): + proof.verify_close_events(before, trace(scroll_rows(1) + [(9, kind, 1, 0)]), {'agent_cancel'}) + with self.assertRaises(AssertionError): + proof.verify_close_events(before, trace(scroll_rows(1) + [(9, 'agent_admitted', 1, 0)]), {'agent_admitted'}) + # Primary events on lane 0 are the trace oracle's job at TRACE_STOP; the + # stopped trace must still fail on them. + stopped = trace(scroll_rows(1) + [(9, 'keyboard_key', 0, 1)], active=False) + self.assertEqual(proof.analyze(stopped)['result'], 'failed') + + +class ActionTests(unittest.TestCase): + def test_single_normal_scroll_fresh_snapshot_unknown_never_replayed(self): + cases = {'owner': (None, 'guard', 'stale', 'unknown', 'same_snapshot', 'cached', 'before_return', + 'same_runtime', 'effect', 'pre_activity', 'dead_before', 'observation', 'lane_mismatch', + 'refused_instead', 'two_lanes'), + 'refused': (None, 'guard', 'stale', 'unknown', 'pre_activity', 'delivered', 'primary_reason', + 'owner_lane', 'admitted', 'pointer_leave', 'moved', 'geometry', 'observation'), + 'recovery': (None, 'guard', 'stale', 'unknown', 'effect', 'other_lane', 'visible', 'margin', + 'refused_instead', 'observation')} + for stage, failures in cases.items(): + for failure in failures: + with self.subTest(stage=stage, failure=failure), ExitStack() as stack: + spec = plan()['agents'][0] + spec['pointer_stage'] = {'owner': 'scroll_down', 'refused': 'scroll_up', + 'recovery': 'scroll_visible' if failure in ('visible', 'margin') else 'scroll_down'}[stage] + owner_lane = None if stage == 'owner' else 0 + actor, observer = client(101), client(102) + if failure == 'same_runtime': + observer = actor + if failure == 'dead_before': + actor.process.poll.return_value = 1 + response = REFUSED if stage == 'refused' else DELIVERED + if failure == 'delivered': + response = DELIVERED + elif failure == 'primary_reason': + response = PRIMARY_REFUSED + elif failure == 'owner_lane': + response = {**REFUSED, 'structuredContent': {**REFUSED['structuredContent'], 'lane': 0}} + elif failure == 'refused_instead': + response = REFUSED + elif failure == 'lane_mismatch': + response = {**DELIVERED, 'structuredContent': {**DELIVERED['structuredContent'], 'lane': 1}} + actor.tool.side_effect = TimeoutError('lost reply') if failure == 'unknown' else None + actor.tool.return_value = response + before = {'snapshot_id': 's00000001', 'proof_image': 'before.png', + 'proof_runtime': {'pid': 101, 'directory': str(Path.cwd())}, + 'proof_observation_started_ns': 10, 'proof_observation_finished_ns': 20} + after = {**before, 'proof_runtime': {'pid': 102, 'directory': str(Path.cwd())}, + 'proof_image': 'after.png', 'proof_observation_started_ns': 50, 'proof_observation_finished_ns': 60} + if failure == 'same_snapshot': + after = dict(before) + elif failure == 'cached': + after.update(proof_observation_started_ns=10, proof_observation_finished_ns=20) + elif failure == 'before_return': + after['proof_observation_started_ns'] = 39 + snapshots = stack.enter_context(patch.object(proof, 'grounded_snapshot', + side_effect=[before, AssertionError('snapshot unavailable')] if failure == 'observation' else [before, after])) + stack.enter_context(patch.object(proof.time, 'monotonic_ns', side_effect= + [5, proof.MAX_GROUNDING_AGE_NS + 11] if failure == 'stale' else [5, 30, 40, 70])) + stack.enter_context(patch.object(proof.pointer_grounding, 'read_pixels', return_value='pixels')) + resolved = 'scroll_up' if spec['pointer_stage'] == 'scroll_visible' else spec['pointer_stage'] + choose = stack.enter_context(patch.object(proof.pointer_grounding, 'visible_inkscape_scroll_stage', + side_effect=proof.pointer_grounding.GroundingUnavailable('margin') if failure == 'margin' else None, + return_value=resolved)) + arguments = {'x': 129, 'y': 219, 'direction': resolved.split('_')[1], 'amount': 1, 'by': 'line'} + oracle = {**ORACLE, 'stage': resolved} + ground = stack.enter_context(patch.object(proof.pointer_grounding, 'action', return_value=(arguments, oracle))) + verify = stack.enter_context(patch.object(proof.pointer_grounding, 'verify', + side_effect=AssertionError('effect missing') if failure == 'effect' else None, return_value={'verified': True})) + rectangle = {**RECTANGLE, 'y': RECTANGLE['y'] - 5} if failure == 'moved' else dict(RECTANGLE) + stack.enter_context(patch.object(proof.pointer_grounding, 'blue_rectangle', return_value=rectangle)) + stack.enter_context(patch.object(proof.pointer_grounding, 'inkscape_geometry', + return_value={**GEOMETRY, 'Y': 9.0} if failure == 'geometry' else dict(GEOMETRY))) + boundary = trace() + rows = [] if stage == 'refused' else scroll_rows(2 if failure == 'other_lane' else 1) + if failure == 'two_lanes': + rows = scroll_rows(1) + scroll_rows(2, 5) + elif failure == 'admitted': + rows = [(1, 'agent_admitted', 2, 0)] + elif failure == 'pointer_leave': + rows = [(1, 'pointer_leave', 1, 0)] + tracer = Mock(collect=Mock(side_effect=[trace(scroll_rows(1)) if failure == 'pre_activity' else trace(), + trace(rows)])) + guard = Mock(side_effect=AssertionError('primary changed') if failure == 'guard' else None) + save, record = Mock(), {'boundary': boundary} + passing = failure in (None, 'visible') + if not passing: + with self.assertRaises(Exception): + proof.action(actor, observer, spec, stage, tracer, guard, save, record, owner_lane=owner_lane) + else: + proof.action(actor, observer, spec, stage, tracer, guard, save, record, owner_lane=owner_lane) + attempted = failure not in ('stale', 'guard', 'pre_activity', 'dead_before', 'same_runtime', 'margin') + self.assertEqual(actor.tool.call_count, int(attempted)) + self.assertFalse(record.get('replayed', False)) + if spec['pointer_stage'] == 'scroll_visible': + choose.assert_called_once_with(before, 'pixels') + else: + choose.assert_not_called() + if failure == 'margin': + ground.assert_not_called() + self.assertEqual(record, {'boundary': boundary}) + else: + ground.assert_called_once_with(before, 'pixels', 'inkscape', resolved) + self.assertEqual(record['tool'], 'scroll') + self.assertEqual(record['grounding']['requested_stage'], spec['pointer_stage']) + if attempted: + self.assertEqual(snapshots.call_args_list[0].args[:3], (actor, spec['target'], spec)) + self.assertEqual(snapshots.call_args_list[1].args[:3], (observer, spec['target'], spec)) + self.assertEqual(snapshots.call_args_list[1].kwargs, {'session': False}) + self.assertEqual(actor.tool.call_args.args, ('scroll', {**arguments, 'pid': 20, 'window_id': 200, + 'session': spec['name'], 'delivery_mode': 'background'})) + if passing: + self.assertEqual(record['outcome'], 'response') + if stage == 'refused': + self.assertTrue(record['no_effect']['verified']) + verify.assert_not_called() + self.assertNotIn('lane', record) + else: + verify.assert_called_once_with(after, 'pixels', oracle) + self.assertEqual((record['lane'], record['status_lane']), (1, 0)) + self.assertEqual(record['trace_verification']['lane'], 1) + if failure == 'unknown': + self.assertEqual(record['outcome'], 'unknown') + self.assertIn('after', record) + self.assertEqual(record['observed_ns'], 40) + self.assertTrue(save.call_count >= 3) + if failure == 'observation': + self.assertEqual(record['observation_error'], 'snapshot unavailable') + self.assertIn('trace_after', record) + self.assertEqual(record['outcome'], 'response') + if failure == 'other_lane': + self.assertNotIn('trace_verification', record) + + +class RunTests(unittest.TestCase): + def test_invalid_plan_preserves_raw_plan_and_cleanup_without_native_calls(self): + with tempfile.TemporaryDirectory() as root: + directory = Path(root) + path = directory / 'plan.json' + candidate = {**plan(), 'case': 'initial_refusal'} + path.write_text(json.dumps(candidate)) + args = SimpleNamespace(plan=path, evidence=directory / 'evidence') + with patch.object(proof, 'ExactDesktop') as desktop, patch('builtins.print'): + self.assertEqual(proof.run(args), 1) + desktop.assert_not_called() + self.assertEqual(json.loads((args.evidence / 'plan.json').read_text()), candidate) + self.assertEqual(json.loads((args.evidence / 'cleanup.json').read_text()), {'errors': []}) + report = json.loads((args.evidence / 'result.json').read_text()) + self.assertEqual(report['result'], 'failed') + self.assertEqual(report['active_lease_conflict'], 'unproven') + self.assertEqual(report['other_lane_recovery'], 'unproven') + + def test_run_orders_owner_refusal_closes_and_recovery_reaping_all_children(self): + for failure in (None, 'owner', 'refused', 'recovery', 'close', 'release', 'primary_event', 'reused', + 'peer_reserved_after_close', 'hover_retired', 'close_side_effect', 'owner_hover_lost', + 'start_lost', 'restarted_trace', 'dirty_preflight'): + with self.subTest(failure=failure), tempfile.TemporaryDirectory() as root, ExitStack() as stack: + directory = Path(root) + path = directory / 'plan.json' + path.write_text(json.dumps(plan())) + args = SimpleNamespace(plan=path, evidence=directory / 'evidence', driver=Path('/driver'), + primary_grab=Path('/grab'), trace_socket=Path('/cua-input-v3.sock'), foreground_journal=Path('/journal')) + observer, owner, refused = client(101), client(102), client(103) + recovery = client(103 if failure == 'reused' else 104) + for actor in (owner, refused, recovery): + actor.tool.return_value = {} + observer.tool.return_value = {'structuredContent': {'screen_width': 1600, 'screen_height': 900}} + acted, closes, launched, rows = set(), [], [], [] + starts, tracing = [0], [False] + def alive(value): + return value.process.poll() is None + def raw_status(): + value = status() + lanes = value['input']['lanes'] + if failure == 'dirty_preflight' and not launched[1:]: + lanes[1]['pointer_focus'] = True + if 'owner' in acted: + lanes[0]['pointer_focus'] = not (failure == 'hover_retired' and not alive(owner)) + lanes[0]['reserved'] = alive(owner) or ('recovery' in acted and alive(recovery)) + if failure == 'owner_hover_lost' and 'refused' in acted: + lanes[0]['pointer_focus'] = False + if 'refused' in acted and (alive(refused) or failure == 'peer_reserved_after_close'): + lanes[1]['reserved'] = True + return value + desktop = Mock() + desktop.raw_status.side_effect = raw_status + desktop.status.side_effect = lambda unreserved=False, allow_passive=False: proof.clear_status( + raw_status(), unreserved=unreserved, allow_passive=allow_passive) + desktop.primary.side_effect = lambda target: {**target, 'cursor': {'x': 30, 'y': 40}, 'workspace': 1} + stack.enter_context(patch.object(proof, 'ExactDesktop', return_value=desktop)) + stack.enter_context(patch.object(proof, 'app_process_identity')) + stack.enter_context(patch.object(proof, 'provenance', return_value={'files': {}})) + def launch(_driver, evidence_directory, profile): + self.assertEqual(profile, proof.PROFILE) + value = (observer, owner, refused, recovery)[len(launched)] + launched.append(evidence_directory.name) + return value + stack.enter_context(patch.object(proof, 'DirectMCP', side_effect=launch)) + stack.enter_context(patch.object(proof, 'grounded_snapshot', return_value={'window_bounds': BOUNDS})) + grab = Mock(poll=Mock(return_value=None)) + grabbed = [False] + def start_grab(*_args, **_kwargs): + self.assertEqual(launched, ['observer'], 'agent runtime launched before the primary fixture') + self.assertEqual(starts[0], 0, 'primary fixture set up inside the trace') + grabbed[0] = True + return grab + stack.enter_context(patch.object(proof.subprocess, 'Popen', side_effect=start_grab)) + grab.terminate.side_effect = lambda: setattr(grab.poll, 'return_value', 0) + stack.enter_context(patch.object(proof, 'stop_process', + side_effect=RuntimeError('release failed') if failure == 'release' else None)) + stack.enter_context(patch.object(proof, 'primary_acknowledgement', return_value='HELD\n')) + stack.enter_context(patch.object(proof, 'wait_for', side_effect=lambda check, **kwargs: check())) + stack.enter_context(patch.object(proof, 'state', side_effect=lambda _: { + 'held': grabbed[0] and grab.poll() is None, 'clicks': 0, 'keys': 0, 'scroll': 0})) + stack.enter_context(patch.object(proof.time, 'monotonic_ns', return_value=0)) + def exchange(command): + if command == 'TRACE_START': + self.assertFalse(tracing[0]) + self.assertTrue(grabbed[0], 'trace started before the primary fixture held') + starts[0] += 1 + tracing[0] = True + if failure == 'start_lost': + raise TimeoutError('trace start acknowledgement lost') + elif command == 'TRACE_STOP': + tracing[0] = False + def collect(): + events = list(rows) + if not tracing[0] and failure == 'primary_event': + events.append((10, 'keyboard_key', 0, 1)) + if not tracing[0] and failure == 'restarted_trace': + events.append((10, 'start', 0, 0)) + return trace(events, active=tracing[0]) + tracer = Mock(exchange=Mock(side_effect=exchange), collect=Mock(side_effect=collect)) + desktop.trace.return_value = tracer + def do_action(actor, obs, spec, stage, trace_obj, guard, save, record, owner_lane=None): + guard() + self.assertIs(obs, observer) + self.assertIn('boundary', record) + self.assertTrue(tracing[0]) + if stage == 'owner': + self.assertIs(actor, owner) + self.assertIsNone(owner_lane) + self.assertEqual(spec['pointer_stage'], 'scroll_down') + self.assertEqual(launched, ['observer', 'owner']) + elif stage == 'refused': + self.assertIs(actor, refused) + self.assertEqual(owner_lane, 0) + self.assertTrue(alive(owner), 'owner closed before the refusal') + self.assertEqual(spec['pointer_stage'], 'scroll_up') + self.assertEqual(spec['name'], 'agent-conflict-refused') + else: + self.assertIs(actor, recovery) + self.assertEqual(owner_lane, 0) + self.assertEqual(closes, [refused, owner], 'recovery before B then A closed in order') + self.assertEqual(spec['pointer_stage'], 'scroll_visible') + acted.add(stage) + if stage != 'refused': + rows.extend(scroll_rows(1, len(rows) + 1)) + record.update(trace_after=collect(), outcome='response', replayed=False, + response=REFUSED if stage == 'refused' else DELIVERED) + if stage == 'owner': + record.update(lane=1, status_lane=0) + save(stage + '-action.json', record) + if failure == stage: + raise AssertionError(stage + ' failed') + stack.enter_context(patch.object(proof, 'action', side_effect=do_action)) + def close(value): + value.process.poll.return_value = 0 + closes.append(value) + if value is refused and failure == 'close_side_effect': + rows.append((9, 'pointer_leave', 1, 0)) + if failure == 'close' and value is recovery: + raise RuntimeError('close failed') + stack.enter_context(patch.object(proof, 'close_owned', side_effect=close)) + stack.enter_context(patch('builtins.print')) + self.assertEqual(proof.run(args), 0 if failure is None else 1) + report = json.loads((args.evidence / 'result.json').read_text()) + self.assertEqual(report['active_lease_conflict'], 'unproven') + self.assertEqual(report['other_lane_recovery'], 'unproven') + self.assertEqual(report['same_process_sibling_window'], 'unproven') + self.assertTrue((args.evidence / 'provenance.json').exists(), report.get('error')) + self.assertTrue((args.evidence / 'trace.json').exists() or failure == 'dirty_preflight', report.get('error')) + if grabbed[0]: + self.assertEqual(grab.poll(), 0, 'primary grab not released') + tracer.close.assert_called_once() + self.assertFalse(tracing[0]) + self.assertTrue(all(not alive(value) for value in (owner, refused, recovery)[:max(len(launched) - 1, 0)])) + self.assertEqual(closes[-1], observer) + cleanup = json.loads((args.evidence / 'cleanup.json').read_text())['errors'] + if failure in ('release', 'close', 'primary_event', 'restarted_trace'): + self.assertTrue(report.get('error') or cleanup) + if failure is None: + self.assertEqual(starts[0], 1) + self.assertEqual(launched, ['observer', 'owner', 'refused', 'recovery']) + # Cleanup re-closes every owned runtime; the ordered stage closes come first. + self.assertEqual(closes[:3], [refused, owner, recovery]) + self.assertEqual(set(report['stages']), + {'owner', 'refused', 'close_refused', 'close_owner', 'recovery', 'close_recovery'}) + for name in ('owner-action.json', 'refused-action.json', 'recovery-action.json', 'close-refused.json', + 'close-owner.json', 'close-refused-trace.json', 'close-owner-trace.json', 'close-recovery.json'): + self.assertTrue((args.evidence / name).exists(), name) + self.assertEqual(report['stages']['close_refused']['teardown']['synthetic_events'], []) + self.assertTrue(report['stages']['close_owner']['status']['input']['lanes'][0]['pointer_focus']) + self.assertFalse(report['stages']['close_owner']['status']['input']['lanes'][0]['reserved']) + self.assertEqual(report['isolation']['result'], 'passed') + self.assertEqual(report['final_status']['input']['lanes'][0]['reserved'], False) + if failure == 'owner': + self.assertEqual(launched, ['observer', 'owner']) + self.assertTrue((args.evidence / 'owner-action.json').exists()) + if failure in ('refused', 'owner_hover_lost'): + self.assertEqual(launched, ['observer', 'owner', 'refused']) + self.assertTrue((args.evidence / 'refused-action.json').exists()) + self.assertEqual(report['stages']['refused']['response'], REFUSED) + if failure == 'owner_hover_lost': + self.assertFalse(report['stages']['refused']['post_action_status']['input']['lanes'][0]['pointer_focus']) + if failure in ('peer_reserved_after_close', 'close_side_effect', 'hover_retired'): + self.assertEqual(launched, ['observer', 'owner', 'refused'], 'recovery followed an unclean close') + self.assertEqual(closes[:2], [refused, owner], 'B must close before A') + if failure == 'reused': + self.assertEqual(report['error']['message'], 'reused runtime process') + self.assertEqual(len(launched), 4) + if failure == 'start_lost': + self.assertEqual(launched, ['observer']) + self.assertEqual(tracer.exchange.call_args_list[-1].args, ('TRACE_STOP',)) + if failure == 'dirty_preflight': + self.assertEqual(launched, ['observer']) + self.assertFalse(grabbed[0]) + self.assertEqual(starts[0], 0) + + +if __name__ == '__main__': + unittest.main() From a949373d5c1cf77dba3b20440e101e7b5251d85c Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Thu, 10 Sep 2026 01:48:33 -0500 Subject: [PATCH 15/27] test(cua-driver): gate desktop faults on observed drag motion --- .../tests/production_desktop_fault_proof.py | 52 ++++++- .../production_desktop_fault_proof_test.py | 145 ++++++++++++++++++ 2 files changed, 195 insertions(+), 2 deletions(-) diff --git a/libs/cua-driver/hyprland-plugin/tests/production_desktop_fault_proof.py b/libs/cua-driver/hyprland-plugin/tests/production_desktop_fault_proof.py index 25e5efaf53..f3f743dca7 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production_desktop_fault_proof.py +++ b/libs/cua-driver/hyprland-plugin/tests/production_desktop_fault_proof.py @@ -6,6 +6,9 @@ pid,instance,uid,starttime,exe; config includes absolute path,device,inode,uid, mode,sha256. Config-disable requires the already sourced, exact ENABLED Lua include from input_config_toggle. Main configuration and policy are never edited. +Optional fault.min_motion_px requires that much Euclidean surface-local motion +from the pre-press pointer position on the same live lane before fault injection. +Omitting it retains the held-drag gate without a minimum motion requirement. Config suspension disconnects the trace transport. Restore the fixture, reconnect without TRACE_START, and require unchanged trace history with cancellation and @@ -28,6 +31,7 @@ from contextlib import contextmanager import hashlib import json +import math import os from pathlib import Path import platform @@ -150,7 +154,9 @@ def file_identity(path): def validate_plan(plan): - assert plan['purpose'] == 'desktop_fault' and set(plan['fault']) == {'kind'} + assert plan['purpose'] == 'desktop_fault' and {'kind'} <= set(plan['fault']) <= {'kind', 'min_motion_px'} + if 'min_motion_px' in plan['fault']: + validate_min_motion(plan['fault']['min_motion_px']) original, _ = fixed_bytes(plan['fault']['kind']) # Reuse the strict existing target/grounding/recovery plan checks unchanged. bounds = plan['agents'][0]['bounds'] @@ -172,6 +178,43 @@ def validate_plan(plan): assert config['sha256'] == digest(original.encode()), 'only the exact dedicated include is permitted' +def validate_min_motion(value): + assert type(value) in (int, float) and math.isfinite(value) and value > 0, 'positive finite min_motion_px required' + + +def drag_motion_px(page, lane): + """Use protocol coordinates, never the primary cursor or another lane.""" + assert set(active_drags(page)) == {lane}, 'motion gate requires the same held lane' + rows = [row for row in page['events'] if row[5] in (1, 2)] + assert all(row[5] == lane for row in rows), 'motion gate crossed lanes' + press = next(row for row in rows if row[2] == 'pointer_button') + assert not any(row[2] == 'pointer_leave' or + (row[2] == 'pointer_enter' and row[0] > press[0]) for row in rows), 'motion gate pointer identity changed' + positions = [row for row in rows if row[2] in ('pointer_enter', 'pointer_motion')] + assert all(len(row) == 9 for row in positions), 'motion gate requires surface coordinates' + before = [row for row in positions if row[0] < press[0]] + assert before, 'motion gate lacks initial pointer position' + motion = [row for row in positions if row[2] == 'pointer_motion' and row[0] > press[0]] + return math.dist(before[-1][7:9], motion[-1][7:9]) if motion else 0 + + +def poll_fault_active(trace, initial, pending, min_motion_px=None, timeout=3): + if min_motion_px is None: + return poll_active(trace, initial, None, [pending], timeout=timeout) + validate_min_motion(min_motion_px) + deadline = time.monotonic() + timeout + previous, lanes = initial, None + while (remaining := deadline - time.monotonic()) > 0: + page, active = poll_active(trace, previous, lanes, [pending], timeout=remaining) + lane = next(iter(active)) + displacement = drag_motion_px(page, lane) + assert time.monotonic() <= deadline, 'motion gate exceeded bounded wait' + if displacement >= min_motion_px: + return page, active + previous, lanes = page, set(active) + raise AssertionError('insufficient drag motion within bounded wait') + + def _guard(config): assert platform.system() == 'Linux' and guest_identity() == config['vm'], 'wrong disposable VM' assert subprocess.run(['systemd-detect-virt', '--vm', '--quiet'], timeout=2).returncode == 0, 'virtual machine required' @@ -324,6 +367,8 @@ def __init__(self, plan, evidence): 'record': str((evidence / 'config-watchdog.json').resolve()), 'files': { 'original': {'path': str(path), 'identity': file_identity(path)}}} self.record, self.restoration = {'result': 'unproven', 'kind': kind}, None + if 'min_motion_px' in plan['fault']: + self.config['min_motion_px'] = self.record['min_motion_px'] = plan['fault']['min_motion_px'] self.child = self.cancel_fd = None self.mutated = False _guard(self.config) @@ -368,7 +413,7 @@ def arm(self): def inject(self, trace, initial, pending, guard): assert self.child and self.child.poll() is None, 'live restoration watchdog required' - prefix, lanes = poll_active(trace, initial, None, [pending]) + prefix, lanes = poll_fault_active(trace, initial, pending, self.config.get('min_motion_px')) gate_ns = time.monotonic_ns() with _locked(self.config): if self.config.get('kind') == 'keymap': @@ -435,6 +480,9 @@ def verify_cancelled(boundary, record, before_restore_ns): """Check the live fault before any restoration or new action is attempted.""" prefix, lane = record['prefix'], record['lane'] assert set(active_drags(prefix)) == {lane} + if 'min_motion_px' in record: + validate_min_motion(record['min_motion_px']) + assert drag_motion_px(prefix, lane) >= record['min_motion_px'], 'fault preceded required drag motion' tail = trace_interval(prefix, boundary) assert prefix['events'][-1][1] <= record['gate_ns'] <= record['requested_ns'] <= record['acknowledged_ns'] assert record['acknowledged_ns'] <= before_restore_ns diff --git a/libs/cua-driver/hyprland-plugin/tests/production_desktop_fault_proof_test.py b/libs/cua-driver/hyprland-plugin/tests/production_desktop_fault_proof_test.py index c7a1c10c92..e3b8b29bcd 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production_desktop_fault_proof_test.py +++ b/libs/cua-driver/hyprland-plugin/tests/production_desktop_fault_proof_test.py @@ -1,6 +1,7 @@ """Portable contracts only: no native VM, compositor, policy or input is touched.""" from contextlib import ExitStack, nullcontext from copy import deepcopy +from itertools import count import json import os from pathlib import Path @@ -82,6 +83,103 @@ def keymap_restoration(): 'keymap_options': options(), 'status': keymap_status(3)} +def motion_trace(distance=12): + page = trace(ACTIVE[:2] + [(2, 'pointer_enter', 1, 0)] + ACTIVE[2:]) + page['events'][2].extend([20, 30]) + page['events'][-1].extend([20 + distance, 30]) + return page + + +class MotionGateTests(unittest.TestCase): + def test_plan_optional_positive_finite_threshold_for_both_faults(self): + for kind in ('config_disable', 'keymap'): + proof.validate_plan(plan(kind)) + for value in (12, 12.5, 0, -1, True, None, '12', float('nan'), float('inf')): + candidate = plan(kind) + candidate['fault']['min_motion_px'] = value + with self.subTest(kind=kind, value=value): + if type(value) in (int, float) and value in (12, 12.5): + proof.validate_plan(candidate) + else: + with self.assertRaises(AssertionError): + proof.validate_plan(candidate) + + def test_omitted_threshold_preserves_held_only_gate(self): + page = trace(ACTIVE[:4]) + transport = Mock(collect=Mock(return_value=page)) + self.assertEqual(proof.poll_fault_active(transport, trace(ACTIVE[:1]), + Mock(done=Mock(return_value=False))), (page, {1: 2_000_000})) + + def test_exact_threshold_waits_for_same_lane_surface_motion(self): + first = motion_trace(11.999) + enough = deepcopy(first) + enough['events'].append([7, 4_500_000, 'pointer_motion', 100, 100, 1, 0, 32, 30]) + enough['count'] += 1 + transport = Mock(collect=Mock(side_effect=[first, enough])) + pending = Mock(done=Mock(return_value=False)) + with patch.object(proof.time, 'sleep') as sleep: + self.assertEqual(proof.poll_fault_active(transport, trace(ACTIVE[:1]), pending, 12), + (enough, {1: 2_000_000})) + self.assertEqual(transport.collect.call_count, 2) + self.assertEqual(pending.done.call_count, 2) + sleep.assert_not_called() + diagonal = motion_trace() + diagonal['events'][-1][7:9] = [23, 34] + self.assertEqual(proof.drag_motion_px(diagonal, 1), 5) + + def test_missing_or_insufficient_post_press_motion_expires_without_replay(self): + for page in (motion_trace(11.999), {**motion_trace(), 'events': motion_trace()['events'][:-1], 'count': 5}): + with self.subTest(page=page), patch.object(proof.time, 'monotonic', side_effect=lambda: next(ticks) / 1000): + ticks = count() + transport = Mock(collect=Mock(return_value=page)) + with self.assertRaisesRegex(AssertionError, 'bounded wait'): + proof.poll_fault_active(transport, trace(ACTIVE[:1]), Mock(done=Mock(return_value=False)), 12, timeout=.02) + self.assertGreater(transport.collect.call_count, 1) + + def test_rejects_missing_coordinates_wrong_lane_changed_identity_and_ended_drag(self): + for failure in ('coordinates', 'initial', 'foreign_lane', 'wrong_lane', 'leave', 'enter', 'release', 'cancel'): + page = motion_trace() + if failure == 'coordinates': + del page['events'][-1][7:9] + elif failure == 'initial': + page['events'][2] = [3, 2_000_000, 'cursor', 100, 100, 0, 0] + elif failure == 'foreign_lane': + page['events'][-1][5] = 2 + elif failure not in ('wrong_lane',): + kind, value = {'leave': ('pointer_leave', 0), 'enter': ('pointer_enter', 0), + 'release': ('pointer_button', 0), 'cancel': ('agent_cancel', 0)}[failure] + page['events'].append([7, 4_500_000, kind, 100, 100, 1, value] + ([32, 30] if failure == 'enter' else [])) + page['count'] += 1 + with self.subTest(failure=failure), self.assertRaises(AssertionError): + proof.drag_motion_px(page, 2 if failure == 'wrong_lane' else 1) + + def test_poll_rejects_changed_trace_history_incomplete_and_stale_reads(self): + first = motion_trace(1) + changed = motion_trace(12) + with self.assertRaisesRegex(AssertionError, 'history'): + proof.poll_fault_active(Mock(collect=Mock(side_effect=[first, changed])), trace(ACTIVE[:1]), + Mock(done=Mock(return_value=False)), 12) + for field, value in (('overflow', True), ('timed_out', True), ('active', False), ('hook', False), ('count', 0)): + with self.subTest(field=field), self.assertRaises(AssertionError): + proof.poll_fault_active(Mock(collect=Mock(return_value={**motion_trace(), field: value})), + trace(ACTIVE[:1]), Mock(done=Mock(return_value=False)), 12) + with patch.object(proof.time, 'monotonic', side_effect=[0, .01, .02, .03, .04, .30]), \ + self.assertRaisesRegex(AssertionError, 'stale'): + proof.poll_fault_active(Mock(collect=Mock(return_value=motion_trace())), trace(ACTIVE[:1]), + Mock(done=Mock(return_value=False)), 12) + + def test_saved_fault_gate_rechecks_threshold_from_trace(self): + candidate = {**record(), 'prefix': motion_trace(), 'min_motion_px': 12} + boundary = motion_trace() + boundary['events'] += [[i + 7, ms * 1_000_000, kind, 100, 100, lane, value] + for i, (ms, kind, lane, value) in enumerate(CANCEL[len(ACTIVE):])] + boundary['count'] = len(boundary['events']) + proof.verify_fault(boundary, candidate, restoration(), action()) + for threshold in (12.001, True, float('nan')): + with self.subTest(threshold=threshold), self.assertRaises(AssertionError): + proof.verify_fault(boundary, {**candidate, 'min_motion_px': threshold}, restoration(), action()) + + class KeymapTests(unittest.TestCase): def test_idle_baseline_allows_only_unreserved_inert_hover_on_either_lane(self): for lane in (0, 1): @@ -467,6 +565,7 @@ def test_constructor_stages_only_fixed_bytes_and_close_preserves_original(self): path.write_bytes(proof.ENABLED.encode()) path.chmod(0o600) candidate = plan() + candidate['fault']['min_motion_px'] = 12 candidate['config'] = {'path': str(path), **proof.file_identity(path)} candidate['compositor']['uid'] = os.getuid() identity = {k: v for k, v in candidate['compositor'].items() if k != 'instance'} @@ -477,6 +576,8 @@ def test_constructor_stages_only_fixed_bytes_and_close_preserves_original(self): stack.enter_context(patch.object(proof, 'production_status', return_value=status())) reload = stack.enter_context(patch.object(proof, '_reload')) fault = proof.ConfigFault(candidate, directory) + self.assertEqual(fault.config['min_motion_px'], 12) + self.assertEqual(fault.record['min_motion_px'], 12) original = proof.file_identity(path) for name, data in (('disabled', proof.DISABLED), ('restored', proof.ENABLED)): staged = Path(fault.config['files'][name]['path']) @@ -586,6 +687,50 @@ def test_injection_requires_pending_press_fresh_gate_live_watchdog(self): if failure == 'lost_reply': self.assertTrue(fault.mutated) + def test_motion_gate_controls_both_faults_without_replay(self): + for kind in ('config_disable', 'keymap'): + for failure in (None, 'done', 'done_after_motion', 'coordinates', 'identity', 'insufficient'): + with self.subTest(kind=kind, failure=failure), ExitStack() as stack: + fault = self.controller() + fault.config.update(kind=kind, min_motion_px=12, instance='exact') + page = motion_trace(1 if failure == 'insufficient' else 12) + if failure == 'coordinates': + del page['events'][-1][7:9] + if failure == 'identity': + page['events'][-1][5] = 2 + ticks = count() + stack.enter_context(patch.object(proof.time, 'monotonic', side_effect=lambda: next(ticks) / 100)) + stack.enter_context(patch.object(proof.time, 'monotonic_ns', side_effect=[5_000_000, 6_000_000, 10_000_000])) + stack.enter_context(patch.object(proof, '_locked', side_effect=lambda _: nullcontext())) + gate = keymap_status(1) + gate['input']['lanes'][0].update(drag_active=True, lease_active=True, held_button=272) + fault.record['before'] = keymap_status(1) + stack.enter_context(patch.object(proof, 'production_status', return_value=gate)) + stack.enter_context(patch.object(proof, 'keymap_options', side_effect=[options(), options(False)])) + stack.enter_context(patch.object(proof, 'file_identity', return_value={})) + # Match _replace's authorization boundary, without touching a file. + atomic = Mock() + def replace(*_args, **kwargs): + kwargs['before_replace']() + atomic() + stack.enter_context(patch.object(proof, '_replace', side_effect=replace)) + reload = stack.enter_context(patch.object(proof, '_reload', return_value=keymap_status(2) + if kind == 'keymap' else status(False))) + pending = Mock(done=Mock(side_effect=[False, True]) if failure == 'done_after_motion' + else Mock(return_value=failure == 'done')) + if failure: + with self.assertRaises(AssertionError): + fault.inject(Mock(collect=Mock(return_value=page)), trace(ACTIVE[:1]), pending, Mock()) + atomic.assert_not_called() + reload.assert_not_called() + self.assertFalse(fault.mutated) + else: + fault.inject(Mock(collect=Mock(return_value=page)), trace(ACTIVE[:1]), pending, Mock()) + atomic.assert_called_once() + reload.assert_called_once() + self.assertTrue(fault.mutated) + self.assertEqual(fault.record['prefix'], page) + def test_keymap_injection_checks_active_native_gate_and_observed_generation(self): for failure in (None, 'gate_changed', 'gate_released', 'not_compiled', 'wrong_options'): with self.subTest(failure=failure), ExitStack() as stack: From 807f43f1ec3c74abc51f0e87a8f22291d0896ebe Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Thu, 10 Sep 2026 02:26:12 -0500 Subject: [PATCH 16/27] test(cua-driver): verify retained inert fault recovery --- .../tests/production_cancel_proof_test.py | 34 ++- .../tests/production_desktop_fault_proof.py | 135 ++++++++- .../production_desktop_fault_proof_test.py | 277 ++++++++++++++++-- 3 files changed, 416 insertions(+), 30 deletions(-) diff --git a/libs/cua-driver/hyprland-plugin/tests/production_cancel_proof_test.py b/libs/cua-driver/hyprland-plugin/tests/production_cancel_proof_test.py index 5c3b787597..e56b0f18c8 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production_cancel_proof_test.py +++ b/libs/cua-driver/hyprland-plugin/tests/production_cancel_proof_test.py @@ -498,6 +498,34 @@ def test_pair_requires_each_item_to_have_budget(self): self.assertFalse(pair_has_dispatch_budget(prepared)) self.assertEqual(prepared[stale]['timing']['pair_gate_ns'], now) + def test_snapshot_freshness_is_relative_to_the_clock_origin(self): + limit = MAX_GROUNDING_AGE_NS - GROUNDING_DISPATCH_RESERVE_NS + for origin in (0, 10**15): + for age in (100_000_000, limit, limit + 1, MAX_GROUNDING_AGE_NS + 1, -1): + with self.subTest(origin=origin, age=age): + clients = [client(100), client(101)] + observed_ns = origin + 1 + retained = {} + with patch('production_cancel_proof.grounded_snapshot', return_value={ + 'proof_observation_started_ns': observed_ns}) as snapshot, \ + patch('production_cancel_proof.time.monotonic_ns', return_value=observed_ns + age): + if age < 0 or age > limit: + with self.assertRaisesRegex(AssertionError, + 'in the future' if age < 0 else 'insufficient dispatch time; no input sent'): + prepare_drags(clients, plan()['agents'], lambda name, value: retained.update({name: value})) + else: + prepared = prepare_drags(clients, plan()['agents'], lambda name, value: retained.update({name: value})) + self.assertTrue(all(item['prepared_ns'] == observed_ns for item in prepared)) + attempts = 2 if age > limit else 1 + self.assertEqual(snapshot.call_count, 2 * attempts) + for index in (0, 1): + item = retained[f'agent-{index}-drag-grounding.json'] + self.assertEqual(item['timing']['pair_grounding_age_ns'], age) + for owned in clients: + owned.tool.assert_not_called() + owned.process.kill.assert_not_called() + owned.process.terminate.assert_not_called() + def test_failed_parallel_grounding_never_dispatches(self): clients = [client(100), client(101)] save = Mock() @@ -892,8 +920,11 @@ def finish(*args, f=future, **kwargs): proof_image = root / 'fresh.png' proof_image.write_bytes(b'synthetic-test-image') snapshot = Mock(return_value={'window_bounds': BOUNDS, 'proof_image': str(proof_image)}) + preparation_clock_ns = 2 * MAX_GROUNDING_AGE_NS if failure == 'prepare_budget': - snapshot.return_value['proof_observation_started_ns'] = 1 + # A monotonic clock has an unspecified origin: timestamp 1 + # need not be stale in a short-lived test process. + snapshot.return_value['proof_observation_started_ns'] = preparation_clock_ns - MAX_GROUNDING_AGE_NS - 1 if failure == 'snapshot': snapshot.side_effect = AssertionError('stale geometry') if failure == 'primary_after': @@ -942,6 +973,7 @@ def executor(*args, **kwargs): return pool if executor_count[0] == 1 else ThreadPoolExecutor(*args, **kwargs) replacements['ThreadPoolExecutor'] = executor replacements['prepare_drag'] = Mock(wraps=prepare_drag) + replacements['time.monotonic_ns'] = Mock(return_value=preparation_clock_ns) else: replacements['prepare_drags'] = observations for name, value in replacements.items(): diff --git a/libs/cua-driver/hyprland-plugin/tests/production_desktop_fault_proof.py b/libs/cua-driver/hyprland-plugin/tests/production_desktop_fault_proof.py index f3f743dca7..f057584bf6 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production_desktop_fault_proof.py +++ b/libs/cua-driver/hyprland-plugin/tests/production_desktop_fault_proof.py @@ -9,6 +9,10 @@ Optional fault.min_motion_px requires that much Euclidean surface-local motion from the pre-press pointer position on the same live lane before fault injection. Omitting it retains the held-drag gate without a minimum motion requirement. +Optional fault.pointer_cleanup=retained_inert certifies continuous inert hover +on the interrupted lane; omitted or cleared retains the original cleanup proof. +The retained proof checks presence, trace continuity, and grounded app identity; +the protocol does not expose a Wayland pointer surface identity. Config suspension disconnects the trace transport. Restore the fixture, reconnect without TRACE_START, and require unchanged trace history with cancellation and @@ -86,8 +90,14 @@ def verify_keymap_options(replies, restored): 'exact sourced keymap option not observed' -def keymap_lanes(status, *, cleared=False): - verify_status(status, True) +def pointer_cleanup(record): + policy = record.get('pointer_cleanup', 'cleared') + assert policy in ('cleared', 'retained_inert'), 'unknown pointer cleanup policy' + return policy + + +def keymap_lanes(status, *, cleared=False, enabled=True): + verify_status(status, enabled) lanes = {row['lane']: row for row in status['input']['lanes']} for row in lanes.values(): assert isinstance(row.get('epoch'), str) and row['epoch'], 'missing compositor epoch' @@ -99,8 +109,27 @@ def keymap_lanes(status, *, cleared=False): return lanes -def verify_keymap_transition(before, after): - old, new = keymap_lanes(before), keymap_lanes(after, cleared=True) +def verify_retained_inert(before, after, lane, *, enabled=True): + """Presence continuity, not an invented surface/resource identity.""" + assert type(lane) is int and lane in (1, 2), 'missing interrupted lane' + old = keymap_lanes(before, enabled=before['configured']) + new = keymap_lanes(after, enabled=enabled) + assert old[lane - 1].get('pointer_focus') is True, 'interrupted pointer was not present' + for index, row in new.items(): + assert type(old[index].get('pointer_focus')) is bool + assert row.get('pointer_focus') is old[index]['pointer_focus'], 'pointer presence changed' + assert row['dispatches'] == old[index]['dispatches'], 'fault or refusal dispatched new input' + assert all(type(row.get(key)) is int and row[key] == 0 for key in ('held_button', 'held_keys')) + assert all(row.get(key) is False for key in + ('drag_active', 'lease_active', 'keyboard_focus', 'reserved')), 'retained hover owns authority' + return new + + +def verify_keymap_transition(before, after, policy='cleared', lane=None): + assert pointer_cleanup({'pointer_cleanup': policy}) == policy + old, new = keymap_lanes(before), keymap_lanes(after, cleared=policy == 'cleared') + if policy == 'retained_inert': + verify_retained_inert(before, after, lane) for lane in old: assert old[lane]['epoch'] == new[lane]['epoch'], 'compositor lane replaced' assert new[lane]['desktop_generation'] > old[lane]['desktop_generation'], 'keymap did not invalidate authority' @@ -154,7 +183,8 @@ def file_identity(path): def validate_plan(plan): - assert plan['purpose'] == 'desktop_fault' and {'kind'} <= set(plan['fault']) <= {'kind', 'min_motion_px'} + assert plan['purpose'] == 'desktop_fault' and {'kind'} <= set(plan['fault']) <= {'kind', 'min_motion_px', 'pointer_cleanup'} + pointer_cleanup(plan['fault']) if 'min_motion_px' in plan['fault']: validate_min_motion(plan['fault']['min_motion_px']) original, _ = fixed_bytes(plan['fault']['kind']) @@ -367,6 +397,11 @@ def __init__(self, plan, evidence): 'record': str((evidence / 'config-watchdog.json').resolve()), 'files': { 'original': {'path': str(path), 'identity': file_identity(path)}}} self.record, self.restoration = {'result': 'unproven', 'kind': kind}, None + if 'pointer_cleanup' in plan['fault']: + self.config['pointer_cleanup'] = self.record['pointer_cleanup'] = pointer_cleanup(plan['fault']) + if pointer_cleanup(plan['fault']) == 'retained_inert': + self.record['target'] = dict(plan['agents'][0]['target']) + self.record['bounds'] = dict(plan['agents'][0]['bounds']) if 'min_motion_px' in plan['fault']: self.config['min_motion_px'] = self.record['min_motion_px'] = plan['fault']['min_motion_px'] self.child = self.cancel_fd = None @@ -376,6 +411,7 @@ def __init__(self, plan, evidence): (evidence / 'pre-fault-status.json').write_text(json.dumps(self.record['before'])) if kind == 'keymap': self.record['keymap_before'] = keymap_options(expected['instance'], True) + if kind == 'keymap' or pointer_cleanup(self.config) == 'retained_inert': idle_lanes(self.record['before']) # Files are prepared before the watchdog and before any live drag. # Known inodes let either process reject an unrelated replacement. @@ -416,7 +452,7 @@ def inject(self, trace, initial, pending, guard): prefix, lanes = poll_fault_active(trace, initial, pending, self.config.get('min_motion_px')) gate_ns = time.monotonic_ns() with _locked(self.config): - if self.config.get('kind') == 'keymap': + if self.config.get('kind') == 'keymap' or pointer_cleanup(self.config) == 'retained_inert': self.record['gate_status'] = production_status(self.config['instance'], True) previous = keymap_lanes(self.record['before']) current = keymap_lanes(self.record['gate_status']) @@ -425,6 +461,9 @@ def inject(self, trace, initial, pending, guard): active = current[next(iter(lanes)) - 1] assert active['drag_active'] is True and active['lease_active'] is True assert type(active['held_button']) is int and active['held_button'] > 0, 'drag ended before keymap fault' + if pointer_cleanup(self.config) == 'retained_inert': + assert active.get('pointer_focus') is True, 'missing live pointer at fault gate' + if self.config.get('kind') == 'keymap': self.record['keymap_before'] = keymap_options(self.config['instance'], True) def authorize(): # Run AFTER all potentially blocking identity checks, directly @@ -437,12 +476,16 @@ def authorize(): assert requested_ns + 3_000_000_000 < self.config['deadline_ns'], 'watchdog deadline too near' self.record.update(prefix=prefix, lane=next(iter(lanes)), gate_ns=gate_ns, requested_ns=requested_ns, watchdog_deadline_ns=self.config['deadline_ns']) + self.config['lane'] = self.record['lane'] self.mutated = True # Lost replies still require restoration. _replace(self.config, False, before_replace=authorize) self.record['after'] = _reload(self.config, False) if self.config.get('kind') == 'keymap': self.record['keymap_after'] = keymap_options(self.config['instance'], False) - verify_keymap_transition(self.record['gate_status'], self.record['after']) + verify_keymap_transition(self.record['gate_status'], self.record['after'], + pointer_cleanup(self.config), self.record['lane']) + elif pointer_cleanup(self.config) == 'retained_inert': + verify_retained_inert(self.record['gate_status'], self.record['after'], self.record['lane'], enabled=False) self.record['acknowledged_ns'] = time.monotonic_ns() self.record['config'] = file_identity(self.config['path']) self.record['result'] = 'observed' @@ -479,6 +522,7 @@ def close(self): def verify_cancelled(boundary, record, before_restore_ns): """Check the live fault before any restoration or new action is attempted.""" prefix, lane = record['prefix'], record['lane'] + policy = pointer_cleanup(record) assert set(active_drags(prefix)) == {lane} if 'min_motion_px' in record: validate_min_motion(record['min_motion_px']) @@ -497,6 +541,11 @@ def verify_cancelled(boundary, record, before_restore_ns): assert len(releases) == 1 and releases[0][6] == 0 and releases[0][0] > cancelled[0][0] assert releases[0][1] < before_restore_ns, 'release was delayed until restoration' assert not any(row[2] in ('pointer_motion', 'pointer_enter') and row[0] > cancelled[0][0] for row in synthetic) + if policy == 'retained_inert': + drag_motion_px(prefix, lane) # Requires coordinates and unchanged pre-press pointer presence. + assert not any(row[2] in ('pointer_leave', 'pointer_enter') for row in synthetic), 'retained pointer left or retargeted' + assert all(row[2] in ('agent_cancel', 'pointer_button', 'keyboard_leave') + for row in synthetic if row[0] >= cancelled[0][0]), 'retained pointer was not inert' stopped = stopped_prefix(boundary) isolation = analyze(stopped) assert isolation['result'] == 'passed' and released_synthetic_input(stopped), isolation @@ -504,6 +553,7 @@ def verify_cancelled(boundary, record, before_restore_ns): def verify_layout_refusal(record): + policy = pointer_cleanup(record) assert record['outcome'] == 'response' and record['replayed'] is False assert record['tool'] in ('click', 'scroll') assert type(record['runtime_pid']) is int and record['runtime_pid'] != record['previous_runtime_pid'] @@ -514,7 +564,11 @@ def verify_layout_refusal(record): content = record['response']['structuredContent'] assert content.get('route') == 'synthetic_events' and content.get('detail') == 'unsupported_layout' assert content.get('code') == 'background_unavailable', 'not the compositor layout refusal' - before, after = keymap_lanes(record['before'], cleared=True), keymap_lanes(record['after'], cleared=True) + before, after = keymap_lanes(record['before'], cleared=policy == 'cleared'), keymap_lanes(record['after'], cleared=policy == 'cleared') + if policy == 'retained_inert': + verify_retained_inert(record['before'], record['before'], record['lane']) + verify_retained_inert(record['before'], record['after'], record['lane']) + verify_target_snapshots(record['target'], record['bounds'], record['snapshot'], record['after_snapshot']) for lane in before: assert all(before[lane][key] == after[lane][key] for key in ('epoch', 'desktop_generation', 'dispatches')), \ 'layout refusal changed compositor state or dispatched input' @@ -542,7 +596,12 @@ def refuse_new_action(client, observer, victim, spec, stage, trace, config, guar 'previous_runtime_pid': victim.process.pid, 'tool': tool, 'prepared_ns': prepared_ns, 'snapshot': snapshot, 'arguments': arguments, 'session': fresh['name'], 'before': production_status(config['instance'], True), 'trace_before': trace.collect()} - keymap_lanes(record['before'], cleared=True) + policy = pointer_cleanup(config) + if policy == 'retained_inert': + record.update(pointer_cleanup=policy, lane=config['lane'], target=dict(spec['target']), bounds=dict(spec['bounds'])) + verify_retained_inert(record['before'], record['before'], record['lane']) + else: + keymap_lanes(record['before'], cleared=True) record['keymap_options'] = keymap_options(config['instance'], False) guard() _guard(config) @@ -568,7 +627,18 @@ def refuse_new_action(client, observer, victim, spec, stage, trace, config, guar save('wrong-layout-action.json', record) +def verify_target_snapshots(target, bounds, before, after): + for snapshot in (before, after): + assert all(type(target.get(key)) is int and target[key] > 0 and + type(snapshot.get(key)) is int and snapshot[key] == target[key] + for key in ('pid', 'window_id')), 'retained hover target identity changed or missing' + assert snapshot['window_bounds'] == bounds, 'retained hover target geometry changed' + assert isinstance(snapshot.get('snapshot_id'), str) and snapshot['snapshot_id'], 'missing target snapshot identity' + assert before['snapshot_id'] != after['snapshot_id'], 'reused target snapshot' + + def verify_fault(boundary, record, restoration, action): + policy = pointer_cleanup(record) original, changed = fixed_bytes(record['kind']) assert record['result'] == 'observed' and restoration['result'] == 'restored' assert record['config']['sha256'] == digest(changed.encode()) @@ -583,8 +653,12 @@ def verify_fault(boundary, record, restoration, action): verify_keymap_options(record['keymap_after'], False) verify_keymap_options(restoration['keymap_options'], True) assert record['keymap_before'] == restoration['keymap_options'], 'original map options not restored' - verify_keymap_transition(record['gate_status'], record['after']) + verify_keymap_transition(record['gate_status'], record['after'], policy, record['lane']) refusal = record['wrong_layout'] + assert pointer_cleanup(refusal) == policy, 'refusal changed pointer cleanup contract' + if policy == 'retained_inert': + assert refusal['lane'] == record['lane'] and refusal['target'] == record['target'] and refusal['bounds'] == record['bounds'] + verify_retained_inert(record['after'], refusal['before'], record['lane']) verify_layout_refusal(refusal) after, before_refusal = keymap_lanes(record['after']), keymap_lanes(refusal['before']) for lane in after: @@ -593,12 +667,34 @@ def verify_fault(boundary, record, restoration, action): trace_interval(record['prefix'], refusal['trace_before']) trace_interval(refusal['trace_after'], boundary) assert record['acknowledged_ns'] <= refusal['prepared_ns'] <= refusal['observed_ns'] < restoration['started_ns'] - verify_keymap_transition(refusal['after'], restoration['status']) + verify_keymap_transition(refusal['after'], restoration['status'], policy, record['lane']) + if policy == 'retained_inert': + gate = keymap_lanes(record['gate_status']) + initial = idle_lanes(record['before']) + assert all(initial[lane][key] == gate[lane][key] + for lane in gate for key in ('epoch', 'desktop_generation')), 'stale native drag gate' + active = gate[record['lane'] - 1] + assert active['drag_active'] is True and active['lease_active'] is True + assert type(active['held_button']) is int and active['held_button'] > 0, 'retained proof has no live native drag gate' + after = verify_retained_inert(record['gate_status'], record['after'], record['lane'], enabled=record['kind'] == 'keymap') + restored = verify_retained_inert(record['after'], restoration['status'], record['lane']) + observed = verify_retained_inert(restoration['status'], record['target_status'], record['lane']) + assert all(restored[lane][key] == observed[lane][key] + for lane in restored for key in ('epoch', 'desktop_generation')), 'desktop changed during target observation' + if record['kind'] == 'config_disable': + for lane in gate: + assert gate[lane]['epoch'] == after[lane]['epoch'], 'disabled compositor lane replaced' + assert after[lane]['epoch'] != restored[lane]['epoch'], 'restoration reused old admission epoch' + assert gate[lane]['desktop_generation'] <= after[lane]['desktop_generation'] <= restored[lane]['desktop_generation'] + verify_target_snapshots(record['target'], record['bounds'], record['target_before'], record['target_after']) isolation = verify_cancelled(boundary, record, restoration['started_ns']) result = {'result': 'verified', 'outcome': fault_outcome(action), 'continuous_isolation': isolation, 'synthetic_cleanup': 'verified', 'saved_document_effect': 'unproven'} if record['kind'] == 'keymap': result['wrong_layout'] = verify_layout_refusal(record['wrong_layout']) + if policy == 'retained_inert': + result['pointer_cleanup'] = {'policy': policy, 'presence_continuity': 'verified', + 'grounded_target_identity': 'verified', 'wayland_surface_identity': 'not_exposed'} return result @@ -659,6 +755,8 @@ def launch(name): assert start_ns <= initial['events'][0][1] <= time.monotonic_ns() assert not active_drags(initial) and not any(row[5] in (1, 2) for row in initial['events']) prepared = prepare_drag(clients[0], spec) + if pointer_cleanup(plan['fault']) == 'retained_inert': + fault.record['target_before'] = prepared['snapshot'] save('drag-grounding.json', prepared) fault.arm() guard() @@ -686,13 +784,26 @@ def launch(name): trace = connect_trace(args.trace_socket, fault.config) # NEVER reset trace history. boundary = trace.collect() save('fault-prefix.json', boundary) + if pointer_cleanup(plan['fault']) == 'retained_inert': + interrupted = grounded_snapshot(observer, spec['target'], spec, session=False) + fault.record['target_after'] = interrupted + observed = production_status(fault.config['instance'], True) + verify_retained_inert(restoration['status'], observed, lane) + fault.record['target_status'] = observed + save('interrupted-status.json', observed) + boundary = trace.collect() + save('fault-prefix.json', boundary) report['fault'] = verify_fault(boundary, fault.record, restoration, report['action']) - save('interrupted-state.json', {'snapshot': grounded_snapshot(observer, spec['target'], spec, session=False), + if pointer_cleanup(plan['fault']) == 'cleared': + interrupted = grounded_snapshot(observer, spec['target'], spec, session=False) + save('interrupted-state.json', {'snapshot': interrupted, 'action': report['action'], 'replayed': False}) close_owned(clients[0]) teardown = trace.collect() save('pre-recovery-prefix.json', teardown) report['runtime_teardown'] = verify_recovery_cleanup(boundary, stopped_prefix(teardown)) + if pointer_cleanup(plan['fault']) == 'retained_inert': + verify_cancelled(teardown, fault.record, restoration['started_ns']) clients.append(launch('recovery')) prefix = recover(clients[-1], observer, clients[0], spec, plan['recovery']['pointer_stage'], trace, teardown, lane, guard, save, report['recovery']) diff --git a/libs/cua-driver/hyprland-plugin/tests/production_desktop_fault_proof_test.py b/libs/cua-driver/hyprland-plugin/tests/production_desktop_fault_proof_test.py index e3b8b29bcd..f45cc34c89 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production_desktop_fault_proof_test.py +++ b/libs/cua-driver/hyprland-plugin/tests/production_desktop_fault_proof_test.py @@ -90,6 +90,220 @@ def motion_trace(distance=12): return page +def target_snapshot(identity): + return {'pid': 20, 'window_id': 200, 'window_bounds': dict(BOUNDS), 'snapshot_id': identity} + + +def retained_evidence(kind='keymap'): + candidate = keymap_record() if kind == 'keymap' else record() + restored = keymap_restoration() if kind == 'keymap' else restoration() + candidate.update(pointer_cleanup='retained_inert', prefix=motion_trace(), + target={'pid': 20, 'window_id': 200}, bounds=dict(BOUNDS), + target_before=target_snapshot('before'), target_after=target_snapshot('after'), + before=keymap_status(1), gate_status=keymap_status(1), after=keymap_status(2)) + candidate['gate_status']['input']['lanes'][0].update( + held_button=272, drag_active=True, lease_active=True, pointer_focus=True, reserved=True) + candidate['after']['input']['lanes'][0]['pointer_focus'] = True + restored['status'] = keymap_status(3) + restored['status']['input']['lanes'][0]['pointer_focus'] = True + boundary = deepcopy(candidate['prefix']) + boundary['events'] += [[7, 8_000_000, 'agent_cancel', 100, 100, 1, 0], + [8, 9_000_000, 'pointer_button', 100, 100, 1, 0]] + boundary['count'] = len(boundary['events']) + if kind == 'keymap': + refused = candidate['wrong_layout'] + refused.update(pointer_cleanup='retained_inert', lane=1, + target=dict(candidate['target']), bounds=dict(BOUNDS), + snapshot=target_snapshot('refusal-before'), after_snapshot=target_snapshot('refusal-after'), + before=deepcopy(candidate['after']), after=deepcopy(candidate['after']), + trace_before=deepcopy(boundary), trace_after=deepcopy(boundary)) + else: + candidate['after']['configured'] = False + candidate['after']['transport']['ready'] = False + candidate['after']['input']['transport_ready'] = False + for row in restored['status']['input']['lanes']: + row['epoch'] += '-restored' + candidate['target_status'] = deepcopy(restored['status']) + return boundary, candidate, restored + + +class RetainedPointerTests(unittest.TestCase): + def test_live_injection_retains_only_the_active_lane_for_both_faults(self): + for kind in ('config_disable', 'keymap'): + for failure in (None, 'gate_absent', 'after_absent', 'after_held'): + with self.subTest(kind=kind, failure=failure), ExitStack() as stack: + _, candidate, _ = retained_evidence(kind) + fault = object.__new__(proof.ConfigFault) + fault.config = {'kind': kind, 'pointer_cleanup': 'retained_inert', 'instance': 'exact', + 'path': '/unused', 'deadline_ns': 10_000_000_000} + fault.child = Mock(poll=Mock(return_value=None)) + fault.record = {'kind': kind, 'pointer_cleanup': 'retained_inert', 'before': candidate['before']} + fault.mutated = False + gate, after = candidate['gate_status'], candidate['after'] + if failure == 'gate_absent': + gate['input']['lanes'][0]['pointer_focus'] = False + if failure == 'after_absent': + after['input']['lanes'][0]['pointer_focus'] = False + if failure == 'after_held': + after['input']['lanes'][0]['held_button'] = 272 + stack.enter_context(patch.object(proof, 'production_status', return_value=gate)) + stack.enter_context(patch.object(proof, 'keymap_options', side_effect=[options(), options(False)])) + stack.enter_context(patch.object(proof, '_locked', side_effect=lambda _: nullcontext())) + stack.enter_context(patch.object(proof, 'poll_fault_active', return_value=(candidate['prefix'], {1: 2}))) + stack.enter_context(patch.object(proof.time, 'monotonic_ns', side_effect=[5_000_000, 6_000_000, 10_000_000])) + atomic = stack.enter_context(patch.object(proof, '_replace', side_effect=lambda *_args, **kwargs: kwargs['before_replace']())) + stack.enter_context(patch.object(proof, '_reload', return_value=after)) + stack.enter_context(patch.object(proof, 'file_identity', return_value=candidate['config'])) + if failure: + with self.assertRaises(AssertionError): + fault.inject(Mock(), trace(ACTIVE[:1]), Mock(done=Mock(return_value=False)), Mock()) + else: + fault.inject(Mock(), trace(ACTIVE[:1]), Mock(done=Mock(return_value=False)), Mock()) + self.assertEqual(fault.config['lane'], 1) + self.assertEqual(fault.record['after']['input']['lanes'][0]['pointer_focus'], True) + self.assertEqual(atomic.call_count, int(failure != 'gate_absent')) + + def test_fresh_refusal_probe_threads_retained_policy_and_exact_target(self): + for missing_focus in (False, True): + with self.subTest(missing_focus=missing_focus), ExitStack() as stack: + boundary, candidate, _ = retained_evidence() + spec = plan('keymap')['agents'][0] + fresh, observer, victim = client(103), client(101), client(100, alive=False) + fresh.tool.side_effect = [{}, layout_refusal()['response']] + before = deepcopy(candidate['after']) + if missing_focus: + before['input']['lanes'][0]['pointer_focus'] = False + stack.enter_context(patch.object(proof, 'production_status', side_effect=[before, candidate['after']])) + stack.enter_context(patch.object(proof, 'app_process_identity')) + stack.enter_context(patch.object(proof, 'grounded_snapshot', side_effect=[ + {**target_snapshot('refusal-before'), 'proof_image': '/synthetic/image'}, target_snapshot('refusal-after')])) + stack.enter_context(patch.object(proof.pointer_grounding, 'read_pixels', return_value=[])) + stack.enter_context(patch.object(proof.pointer_grounding, 'action', return_value=({'x': 20, 'y': 20}, {}))) + stack.enter_context(patch.object(proof, 'keymap_options', return_value=options(False))) + stack.enter_context(patch.object(proof, '_guard')) + stack.enter_context(patch.object(proof, 'file_identity', return_value={'inode': 20})) + stack.enter_context(patch.object(proof.time, 'monotonic_ns', side_effect=[10_100_000, 10_500_000, 11_000_000])) + config = {'instance': 'exact', 'path': '/unused', 'deadline_ns': 12_000_000_000, + 'pointer_cleanup': 'retained_inert', 'lane': 1, + 'files': {'disabled': {'identity': {'inode': 20}}}} + args = (fresh, observer, victim, spec, 'click_b2', Mock(collect=Mock(return_value=boundary)), config, Mock(), Mock()) + if missing_focus: + with self.assertRaises(AssertionError): + proof.refuse_new_action(*args) + self.assertEqual(fresh.tool.call_count, 1) + else: + observed = proof.refuse_new_action(*args) + self.assertEqual(observed['pointer_cleanup'], 'retained_inert') + self.assertEqual(observed['target'], spec['target']) + self.assertEqual(observed['verification']['result'], 'verified') + + def test_opt_in_and_legacy_default_are_distinct(self): + for kind in ('config_disable', 'keymap'): + for policy in ('cleared', 'retained_inert', None, True, 'retained', {}, []): + candidate = plan(kind) + candidate['fault']['pointer_cleanup'] = policy + with self.subTest(kind=kind, policy=policy): + if policy in ('cleared', 'retained_inert'): + proof.validate_plan(candidate) + else: + with self.assertRaises(AssertionError): + proof.validate_plan(candidate) + boundary, candidate, restored = retained_evidence(kind) + result = proof.verify_fault(boundary, candidate, restored, action()) + self.assertEqual(result['pointer_cleanup']['presence_continuity'], 'verified') + self.assertEqual(result['pointer_cleanup']['wayland_surface_identity'], 'not_exposed') + boundary, candidate, restored = retained_evidence() + del candidate['pointer_cleanup'] + with self.assertRaises(AssertionError): + proof.verify_fault(boundary, candidate, restored, action()) + # The original saved evidence still uses its strict keymap interpretation. + self.assertEqual(proof.verify_fault(trace(CANCEL), keymap_record(), keymap_restoration(), action())['result'], 'verified') + + def test_every_cleanup_status_requires_inert_authority_and_exact_pointer_presence(self): + fields = [('held_button', 272), ('held_button', False), ('held_keys', 1), ('held_keys', True), + ('drag_active', True), ('lease_active', True), ('keyboard_focus', True), + ('reserved', True), ('reserved', None), ('pointer_focus', False), + ('pointer_focus', 1), ('pointer_focus', None), ('dispatches', 2)] + for kind in ('config_disable', 'keymap'): + stages = ['after', 'restored', 'target_status'] + (['refusal-before', 'refusal-after'] if kind == 'keymap' else []) + for stage in stages: + for key, value in fields: + boundary, candidate, restored = retained_evidence(kind) + observed = (restored['status'] if stage == 'restored' else + candidate['wrong_layout'][stage.split('-')[1]] if stage.startswith('refusal-') else candidate[stage]) + observed['input']['lanes'][0][key] = value + with self.subTest(kind=kind, stage=stage, key=key, value=value), self.assertRaises(AssertionError): + proof.verify_fault(boundary, candidate, restored, action()) + boundary, candidate, restored = retained_evidence(kind) + candidate['after']['input']['lanes'][1]['pointer_focus'] = True + with self.assertRaisesRegex(AssertionError, 'presence'): + proof.verify_fault(boundary, candidate, restored, action()) + + def test_retained_trace_cannot_hide_leave_retarget_motion_new_input_or_missing_release(self): + for kind in ('config_disable', 'keymap'): + for event in ('pointer_leave', 'pointer_enter', 'pointer_motion', 'keyboard_enter', + 'keyboard_key', 'pointer_axis', 'agent_admitted', 'agent_drag_end', 'agent_action_end'): + boundary, candidate, restored = retained_evidence(kind) + boundary['events'].append([9, 11_000_000, event, 100, 100, 1, 0] + + ([32, 30] if event in ('pointer_enter', 'pointer_motion') else [])) + boundary['count'] += 1 + with self.subTest(kind=kind, event=event), self.assertRaises(AssertionError): + proof.verify_fault(boundary, candidate, restored, action()) + for missing in ('release', 'cancel', 'coordinates'): + boundary, candidate, restored = retained_evidence(kind) + if missing == 'coordinates': + del candidate['prefix']['events'][-1][7:9] + del boundary['events'][5][7:9] + else: + boundary['events'].pop(-1 if missing == 'release' else -2) + boundary['count'] -= 1 + with self.subTest(kind=kind, missing=missing), self.assertRaises(AssertionError): + proof.verify_fault(boundary, candidate, restored, action()) + + def test_target_process_window_geometry_and_snapshot_identity_are_required(self): + for kind in ('config_disable', 'keymap'): + for stage in ('target_before', 'target_after', 'snapshot', 'after_snapshot'): + if kind == 'config_disable' and stage in ('snapshot', 'after_snapshot'): + continue + for key, value in (('pid', 21), ('window_id', 201), ('window_id', None), + ('window_bounds', {**BOUNDS, 'x': 999}), ('snapshot_id', '')): + boundary, candidate, restored = retained_evidence(kind) + snapshot = candidate[stage] if stage.startswith('target_') else candidate['wrong_layout'][stage] + snapshot[key] = value + with self.subTest(kind=kind, stage=stage, key=key), self.assertRaises(AssertionError): + proof.verify_fault(boundary, candidate, restored, action()) + boundary, candidate, restored = retained_evidence(kind) + candidate['target_after']['snapshot_id'] = candidate['target_before']['snapshot_id'] + with self.assertRaisesRegex(AssertionError, 'reused'): + proof.verify_fault(boundary, candidate, restored, action()) + + def test_refusal_has_no_synthetic_events_even_with_unchanged_status(self): + for event in ('pointer_enter', 'pointer_leave', 'pointer_motion', 'pointer_button', + 'agent_admitted', 'keyboard_leave'): + _, candidate, _ = retained_evidence() + refusal = candidate['wrong_layout'] + refusal['trace_after']['events'].append([9, 10_800_000, event, 100, 100, 1, 0]) + refusal['trace_after']['count'] += 1 + with self.subTest(event=event), self.assertRaisesRegex(AssertionError, 'synthetic input'): + proof.verify_layout_refusal(refusal) + + def test_gate_epoch_generation_refusal_policy_and_lane_cannot_be_substituted(self): + for change in ('gate', 'epoch', 'generation', 'policy', 'lane', 'target', 'dispatch'): + boundary, candidate, restored = retained_evidence() + if change == 'gate': + candidate['gate_status']['input']['lanes'][0]['held_button'] = 0 + elif change in ('epoch', 'generation'): + candidate['after']['input']['lanes'][0][ + 'epoch' if change == 'epoch' else 'desktop_generation'] = 'replacement' if change == 'epoch' else 1 + elif change == 'dispatch': + candidate['gate_status']['input']['lanes'][0]['dispatches'] = 2 + else: + candidate['wrong_layout'][{'policy': 'pointer_cleanup', 'lane': 'lane', 'target': 'target'}[change]] = { + 'policy': 'cleared', 'lane': 2, 'target': {'pid': 21, 'window_id': 201}}[change] + with self.subTest(change=change), self.assertRaises(AssertionError): + proof.verify_fault(boundary, candidate, restored, action()) + + class MotionGateTests(unittest.TestCase): def test_plan_optional_positive_finite_threshold_for_both_faults(self): for kind in ('config_disable', 'keymap'): @@ -566,6 +780,7 @@ def test_constructor_stages_only_fixed_bytes_and_close_preserves_original(self): path.chmod(0o600) candidate = plan() candidate['fault']['min_motion_px'] = 12 + candidate['fault']['pointer_cleanup'] = 'retained_inert' candidate['config'] = {'path': str(path), **proof.file_identity(path)} candidate['compositor']['uid'] = os.getuid() identity = {k: v for k, v in candidate['compositor'].items() if k != 'instance'} @@ -573,11 +788,14 @@ def test_constructor_stages_only_fixed_bytes_and_close_preserves_original(self): stack.enter_context(patch.object(proof.subprocess, 'run', return_value=Mock(returncode=0))) stack.enter_context(patch.object(proof, '_identity', return_value=identity)) stack.enter_context(patch.object(proof, '_guard')) - stack.enter_context(patch.object(proof, 'production_status', return_value=status())) + stack.enter_context(patch.object(proof, 'production_status', return_value=keymap_status(1))) reload = stack.enter_context(patch.object(proof, '_reload')) fault = proof.ConfigFault(candidate, directory) self.assertEqual(fault.config['min_motion_px'], 12) self.assertEqual(fault.record['min_motion_px'], 12) + self.assertEqual(fault.config['pointer_cleanup'], 'retained_inert') + self.assertEqual(fault.record['pointer_cleanup'], 'retained_inert') + self.assertEqual(fault.record['target'], candidate['agents'][0]['target']) original = proof.file_identity(path) for name, data in (('disabled', proof.DISABLED), ('restored', proof.ENABLED)): staged = Path(fault.config['files'][name]['path']) @@ -798,17 +1016,27 @@ def test_invalid_plan_fails_before_native_controller_or_allocation(self): launch.assert_not_called() def test_reconnect_without_reset_restore_before_snapshot_and_new_action(self): - cases = [(kind, failure) for kind in ('config_disable', 'keymap') for failure in (None, 'inject', 'restore', 'recovery')] - cases += [('keymap', 'refusal'), ('keymap', 'cancel')] - for kind, failure in cases: - with self.subTest(kind=kind, failure=failure), tempfile.TemporaryDirectory() as root, ExitStack() as stack: + cases = [(kind, failure, 'cleared') for kind in ('config_disable', 'keymap') for failure in (None, 'inject', 'restore', 'recovery')] + cases += [('keymap', 'refusal', 'cleared'), ('keymap', 'cancel', 'cleared')] + cases += [(kind, failure, 'retained_inert') for kind in ('config_disable', 'keymap') + for failure in (None, 'recovery', 'snapshot_leave')] + for kind, failure, policy in cases: + with self.subTest(kind=kind, failure=failure, policy=policy), tempfile.TemporaryDirectory() as root, ExitStack() as stack: directory = Path(root) path = directory / 'plan.json' - path.write_text(json.dumps(plan(kind))) + candidate = plan(kind) + if policy == 'retained_inert': + candidate['fault']['pointer_cleanup'] = policy + path.write_text(json.dumps(candidate)) args = SimpleNamespace(plan=path, evidence=directory / 'evidence', driver=Path('/driver'), primary_grab=Path('/grab'), foreground_journal=Path('/journal'), trace_socket=Path('/cua-input-v3.sock')) order = [] - fault = Mock(record=keymap_record() if kind == 'keymap' else record()) + cancelled = trace(CANCEL) + fault_record = keymap_record() if kind == 'keymap' else record() + restored = keymap_restoration() if kind == 'keymap' else restoration() + if policy == 'retained_inert': + cancelled, fault_record, restored = retained_evidence(kind) + fault = Mock(record=fault_record, config={'instance': 'exact', 'pointer_cleanup': policy, 'lane': 1}) def inject(*_): order.append('inject') if failure == 'inject': @@ -819,7 +1047,7 @@ def restore(): order.append('restore') if failure == 'restore': raise AssertionError('restore failed') - return keymap_restoration() if kind == 'keymap' else restoration() + return restored fault.restore.side_effect = restore stack.enter_context(patch.object(proof, 'ConfigFault', return_value=fault)) stack.enter_context(patch.object(proof, 'provenance', return_value={'files': {}})) @@ -829,9 +1057,10 @@ def restore(): stack.enter_context(patch.object(proof, 'DirectMCP', side_effect=[agent, observer, refused, fresh] if kind == 'keymap' else [agent, observer, fresh])) def snapshot(*_args, **_kwargs): order.append('snapshot') - return {'window_bounds': dict(BOUNDS)} + return target_snapshot('snapshot-' + str(len(order))) stack.enter_context(patch.object(proof, 'grounded_snapshot', side_effect=snapshot)) - stack.enter_context(patch.object(proof, 'prepare_drag', return_value={})) + stack.enter_context(patch.object(proof, 'prepare_drag', return_value={'snapshot': target_snapshot('prepared')})) + stack.enter_context(patch.object(proof, 'production_status', return_value=restored['status'])) stack.enter_context(patch.object(proof, 'call_drag', return_value=action())) grab = Mock(poll=Mock(return_value=None)) grab.terminate.side_effect = lambda: setattr(grab.poll, 'return_value', 0) @@ -843,18 +1072,28 @@ def snapshot(*_args, **_kwargs): stack.enter_context(patch.object(proof, 'state', return_value={'held': True, 'clicks': 0, 'keys': 0, 'scroll': 0})) stack.enter_context(patch.object(proof, 'require_primary_active')) stack.enter_context(patch.object(proof.time, 'monotonic_ns', side_effect=[0, 0, 0, 11_500_000])) - first = Mock(hello={'protocol': 3}, collect=Mock(side_effect=[trace(ACTIVE[:1]), trace(ACTIVE if failure == 'cancel' else CANCEL)])) - page = trace(CANCEL + [(14, 'agent_admitted', 1, 0), (15, 'pointer_button', 1, 1), - (16, 'pointer_button', 1, 0), (17, 'agent_action_end', 1, 0)]) - last = proof.stopped_prefix(page if failure is None else trace(CANCEL)) - second = Mock(hello={'protocol': 3}, collect=Mock(side_effect=[trace(CANCEL), trace(CANCEL), last])) + first = Mock(hello={'protocol': 3}, collect=Mock(side_effect=[trace(ACTIVE[:1]), trace(ACTIVE) if failure == 'cancel' else cancelled])) + page = deepcopy(cancelled) + for ms, event, value in ((14, 'agent_admitted', 0), (15, 'pointer_button', 1), + (16, 'pointer_button', 0), (17, 'agent_action_end', 0)): + page['events'].append([len(page['events']) + 1, ms * 1_000_000, event, 100, 100, 1, value]) + page['count'] = len(page['events']) + last = proof.stopped_prefix(page if failure is None else cancelled) + observations = [cancelled, cancelled, last] + if policy == 'retained_inert': + observed = deepcopy(cancelled) + if failure == 'snapshot_leave': + observed['events'].append([9, 14_000_000, 'pointer_leave', 100, 100, 1, 0]) + observed['count'] += 1 + observations = [cancelled, observed, cancelled, last] + second = Mock(hello={'protocol': 3}, collect=Mock(side_effect=observations)) stack.enter_context(patch.object(proof, 'connect_trace', side_effect=[first, second])) def refuse(*args): order.append('refusal') self.assertEqual(agent.process.poll(), 0) if failure == 'refusal': raise AssertionError('wrong layout did not refuse') - return layout_refusal() + return fault_record['wrong_layout'] if policy == 'retained_inert' else layout_refusal() probe = stack.enter_context(patch.object(proof, 'refuse_new_action', side_effect=refuse)) def recover(*args): order.append('recovery') @@ -871,7 +1110,7 @@ def recover(*args): self.assertFalse(any(call.args == ('TRACE_START',) for call in second.exchange.call_args_list)) self.assertEqual(agent.process.poll(), 0) self.assertEqual(observer.process.poll(), 0) - if failure not in ('inject', 'restore', 'refusal', 'cancel'): + if failure not in ('inject', 'restore', 'refusal', 'cancel', 'snapshot_leave'): self.assertLess(order.index('restore'), len(order) - 1 - order[::-1].index('snapshot')) self.assertLess(order.index('restore'), order.index('recovery')) self.assertTrue((args.evidence / 'pre-recovery-prefix.json').is_file()) @@ -880,6 +1119,10 @@ def recover(*args): self.assertEqual(refused.process.poll(), 0) if kind == 'config_disable' or failure in ('inject', 'cancel'): probe.assert_not_called() + if policy == 'retained_inert' and failure != 'snapshot_leave': + self.assertEqual(fault.record['target_before']['snapshot_id'], 'prepared') + self.assertEqual(fault.record['target_after']['pid'], candidate['agents'][0]['target']['pid']) + self.assertTrue((args.evidence / 'interrupted-status.json').is_file()) report = json.loads((args.evidence / 'result.json').read_text()) self.assertFalse(report['full_desktop_matrix']) self.assertFalse(report['physical_hardware']) From 5295fb6dc913c67875ad471037ac1bda9057d4d6 Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Thu, 10 Sep 2026 02:28:32 -0500 Subject: [PATCH 17/27] test(cua-driver): scope fault snapshots to observing runtime --- .../tests/production_desktop_fault_proof.py | 10 +++- .../production_desktop_fault_proof_test.py | 52 ++++++++++++++++--- 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/libs/cua-driver/hyprland-plugin/tests/production_desktop_fault_proof.py b/libs/cua-driver/hyprland-plugin/tests/production_desktop_fault_proof.py index f057584bf6..454d61e8a0 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production_desktop_fault_proof.py +++ b/libs/cua-driver/hyprland-plugin/tests/production_desktop_fault_proof.py @@ -634,7 +634,15 @@ def verify_target_snapshots(target, bounds, before, after): for key in ('pid', 'window_id')), 'retained hover target identity changed or missing' assert snapshot['window_bounds'] == bounds, 'retained hover target geometry changed' assert isinstance(snapshot.get('snapshot_id'), str) and snapshot['snapshot_id'], 'missing target snapshot identity' - assert before['snapshot_id'] != after['snapshot_id'], 'reused target snapshot' + runtime = snapshot.get('proof_runtime', {}) + assert type(runtime.get('pid')) is int and runtime['pid'] > 0, 'missing observation runtime' + # Snapshot counters belong to the observing Driver process, not the app. + assert (before['proof_runtime']['pid'], before['snapshot_id']) != \ + (after['proof_runtime']['pid'], after['snapshot_id']), 'reused target snapshot' + times = [snapshot.get(key) for snapshot in (before, after) + for key in ('proof_observation_started_ns', 'proof_observation_finished_ns')] + assert all(type(value) is int and value >= 0 for value in times), 'invalid observation timestamp' + assert times == sorted(times) and times[0] < times[2], 'stale or out-of-order observation' def verify_fault(boundary, record, restoration, action): diff --git a/libs/cua-driver/hyprland-plugin/tests/production_desktop_fault_proof_test.py b/libs/cua-driver/hyprland-plugin/tests/production_desktop_fault_proof_test.py index f45cc34c89..8466e0117f 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production_desktop_fault_proof_test.py +++ b/libs/cua-driver/hyprland-plugin/tests/production_desktop_fault_proof_test.py @@ -90,8 +90,10 @@ def motion_trace(distance=12): return page -def target_snapshot(identity): - return {'pid': 20, 'window_id': 200, 'window_bounds': dict(BOUNDS), 'snapshot_id': identity} +def target_snapshot(identity, *, runtime=100, observed_ns=1_000_000): + return {'pid': 20, 'window_id': 200, 'window_bounds': dict(BOUNDS), 'snapshot_id': identity, + 'proof_runtime': {'pid': runtime, 'directory': f'/synthetic/runtime-{runtime}'}, + 'proof_observation_started_ns': observed_ns, 'proof_observation_finished_ns': observed_ns + 100_000} def retained_evidence(kind='keymap'): @@ -99,7 +101,7 @@ def retained_evidence(kind='keymap'): restored = keymap_restoration() if kind == 'keymap' else restoration() candidate.update(pointer_cleanup='retained_inert', prefix=motion_trace(), target={'pid': 20, 'window_id': 200}, bounds=dict(BOUNDS), - target_before=target_snapshot('before'), target_after=target_snapshot('after'), + target_before=target_snapshot('before'), target_after=target_snapshot('after', runtime=101, observed_ns=14_000_000), before=keymap_status(1), gate_status=keymap_status(1), after=keymap_status(2)) candidate['gate_status']['input']['lanes'][0].update( held_button=272, drag_active=True, lease_active=True, pointer_focus=True, reserved=True) @@ -114,7 +116,8 @@ def retained_evidence(kind='keymap'): refused = candidate['wrong_layout'] refused.update(pointer_cleanup='retained_inert', lane=1, target=dict(candidate['target']), bounds=dict(BOUNDS), - snapshot=target_snapshot('refusal-before'), after_snapshot=target_snapshot('refusal-after'), + snapshot=target_snapshot('refusal-before', runtime=103, observed_ns=10_200_000), + after_snapshot=target_snapshot('refusal-after', runtime=101, observed_ns=10_700_000), before=deepcopy(candidate['after']), after=deepcopy(candidate['after']), trace_before=deepcopy(boundary), trace_after=deepcopy(boundary)) else: @@ -176,7 +179,8 @@ def test_fresh_refusal_probe_threads_retained_policy_and_exact_target(self): stack.enter_context(patch.object(proof, 'production_status', side_effect=[before, candidate['after']])) stack.enter_context(patch.object(proof, 'app_process_identity')) stack.enter_context(patch.object(proof, 'grounded_snapshot', side_effect=[ - {**target_snapshot('refusal-before'), 'proof_image': '/synthetic/image'}, target_snapshot('refusal-after')])) + {**target_snapshot('refusal-before', runtime=103, observed_ns=10_200_000), 'proof_image': '/synthetic/image'}, + target_snapshot('refusal-after', runtime=101, observed_ns=10_700_000)])) stack.enter_context(patch.object(proof.pointer_grounding, 'read_pixels', return_value=[])) stack.enter_context(patch.object(proof.pointer_grounding, 'action', return_value=({'x': 20, 'y': 20}, {}))) stack.enter_context(patch.object(proof, 'keymap_options', return_value=options(False))) @@ -274,9 +278,45 @@ def test_target_process_window_geometry_and_snapshot_identity_are_required(self) proof.verify_fault(boundary, candidate, restored, action()) boundary, candidate, restored = retained_evidence(kind) candidate['target_after']['snapshot_id'] = candidate['target_before']['snapshot_id'] + self.assertEqual(proof.verify_fault(boundary, candidate, restored, action())['result'], 'verified') + candidate['target_after']['proof_runtime'] = deepcopy(candidate['target_before']['proof_runtime']) with self.assertRaisesRegex(AssertionError, 'reused'): proof.verify_fault(boundary, candidate, restored, action()) + def test_refusal_snapshot_ids_are_scoped_to_the_observer_and_require_new_observations(self): + for same_runtime in (False, True): + for same_id in (False, True): + _, candidate, _ = retained_evidence() + refusal = candidate['wrong_layout'] + if same_runtime: + refusal['after_snapshot']['proof_runtime'] = deepcopy(refusal['snapshot']['proof_runtime']) + if same_id: + refusal['after_snapshot']['snapshot_id'] = refusal['snapshot']['snapshot_id'] + with self.subTest(same_runtime=same_runtime, same_id=same_id): + if same_runtime and same_id: + with self.assertRaisesRegex(AssertionError, 'reused'): + proof.verify_layout_refusal(refusal) + else: + self.assertEqual(proof.verify_layout_refusal(refusal)['result'], 'verified') + + def test_cross_runtime_counter_collision_cannot_hide_stale_or_invalid_timing(self): + for key, value in (('proof_observation_started_ns', None), ('proof_observation_started_ns', True), + ('proof_observation_started_ns', -1), ('proof_observation_started_ns', 1_000_000), + ('proof_observation_started_ns', 1_050_000), + ('proof_observation_finished_ns', 13_000_000), ('proof_runtime', {}), + ('proof_runtime', {'pid': True}), ('proof_runtime', {'pid': 0})): + boundary, candidate, restored = retained_evidence() + candidate['target_after']['snapshot_id'] = candidate['target_before']['snapshot_id'] + candidate['target_after'][key] = value + with self.subTest(key=key, value=value), self.assertRaises(AssertionError): + proof.verify_fault(boundary, candidate, restored, action()) + _, candidate, _ = retained_evidence() + refusal = candidate['wrong_layout'] + refusal['after_snapshot']['snapshot_id'] = refusal['snapshot']['snapshot_id'] + refusal['after_snapshot']['proof_observation_started_ns'] = refusal['snapshot']['proof_observation_started_ns'] + with self.assertRaisesRegex(AssertionError, 'out-of-order'): + proof.verify_layout_refusal(refusal) + def test_refusal_has_no_synthetic_events_even_with_unchanged_status(self): for event in ('pointer_enter', 'pointer_leave', 'pointer_motion', 'pointer_button', 'agent_admitted', 'keyboard_leave'): @@ -1057,7 +1097,7 @@ def restore(): stack.enter_context(patch.object(proof, 'DirectMCP', side_effect=[agent, observer, refused, fresh] if kind == 'keymap' else [agent, observer, fresh])) def snapshot(*_args, **_kwargs): order.append('snapshot') - return target_snapshot('snapshot-' + str(len(order))) + return target_snapshot('snapshot-' + str(len(order)), runtime=101, observed_ns=14_000_000) stack.enter_context(patch.object(proof, 'grounded_snapshot', side_effect=snapshot)) stack.enter_context(patch.object(proof, 'prepare_drag', return_value={'snapshot': target_snapshot('prepared')})) stack.enter_context(patch.object(proof, 'production_status', return_value=restored['status'])) From b4b7d1024322a2d48db7403122796b219f3eeb7c Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Thu, 10 Sep 2026 02:50:12 -0500 Subject: [PATCH 18/27] test(cua-driver): verify inert pointer recovery across desktop faults --- .../tests/production_active_lock_proof.py | 52 +++- .../production_active_lock_proof_test.py | 120 +++++++- .../tests/production_desktop_fault_proof.py | 49 ++- .../production_desktop_fault_proof_test.py | 54 +++- .../tests/production_lock_refusal_proof.py | 84 +++-- .../production_lock_refusal_proof_test.py | 95 +++++- .../tests/production_session_fault_proof.py | 150 ++++++++- .../production_session_fault_proof_test.py | 287 ++++++++++++++++++ 8 files changed, 837 insertions(+), 54 deletions(-) diff --git a/libs/cua-driver/hyprland-plugin/tests/production_active_lock_proof.py b/libs/cua-driver/hyprland-plugin/tests/production_active_lock_proof.py index 55bceb62ff..aa8b77d357 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production_active_lock_proof.py +++ b/libs/cua-driver/hyprland-plugin/tests/production_active_lock_proof.py @@ -38,16 +38,19 @@ from production_realapp_proof import (PRIMARY_LIFETIME_MS, capacity_lane, check_response, primary_acknowledgement, provenance, require_primary_active, trace_interval) -from production_session_fault_proof import connect_trace, lanes, power, production_status -from production_desktop_fault_proof import idle_lanes +from production_session_fault_proof import (connect_trace, lanes, power, production_status, + validate_fault_options, verify_held_gate) +from production_desktop_fault_proof import idle_lanes, pointer_cleanup, poll_fault_active, verify_retained_inert from realapp_proof import cleanup_all, released_synthetic_input def validate_plan(plan): - assert plan['purpose'] == 'active_lock' and plan['fault'] == {'kind': 'lock'} + assert plan['purpose'] == 'active_lock' and plan['fault']['kind'] == 'lock' + validate_fault_options(plan['fault']) stages = ['scroll_down', 'scroll_up'] if plan.get('app_profile') == 'inkscape-only' else ['click_a1', 'click_b2'] assert plan['recovery'] == {'pointer_stages': stages} - lock_plan({**plan, 'purpose': 'lock_refusal', 'recovery': {'pointer_stage': stages[0]}}) + lock_plan({**plan, 'purpose': 'lock_refusal', 'recovery': {'pointer_stage': stages[0]}, + 'fault': {k: v for k, v in plan['fault'].items() if k != 'min_motion_px'}}) def recovery_stage(snapshot, app='calc'): @@ -86,8 +89,11 @@ def held_status(status, lane): assert row.get('reserved') is False, 'unowned lane is reserved' -def cancelled_status(before, after): - old, new = lanes(before), lanes(after, cleared=True) +def cancelled_status(before, after, policy='cleared', lane=None): + pointer_cleanup({'pointer_cleanup': policy}) + old, new = lanes(before), lanes(after, cleared=True, allow_passive=policy == 'retained_inert') + if policy == 'retained_inert': + verify_retained_inert(before, after, lane) assert set(old) == set(new) == {0, 1} for lane in old: assert old[lane]['epoch'] == new[lane]['epoch'], 'compositor lane replaced' @@ -122,17 +128,24 @@ def inject(self, trace, initial, pending): self.check_binary() self.check_running_binary() power(self.config, True) - first, _ = poll_active(trace, initial, None, [pending], timeout=1) + motion = self.config.get('min_motion_px') + if motion is not None: + first, _ = poll_fault_active(trace, initial, pending, motion, timeout=1) + else: + first, _ = poll_active(trace, initial, None, [pending], timeout=1) status_started = time.monotonic_ns() gate = production_status(self.config) - page, active = poll_active(trace, first, None, [pending], timeout=0.25) + if motion is not None: + page, active = poll_fault_active(trace, first, pending, motion, timeout=0.25) + else: + page, active = poll_active(trace, first, None, [pending], timeout=0.25) lane = next(iter(active)) held_status(gate, lane) for key, row in lanes(self.record['before']).items(): assert all(row[field] == lanes(gate)[key][field] for field in ('epoch', 'desktop_generation', 'dispatches')), 'desktop changed before LOCK' self.record.update(gate_status=gate, status_started_ns=status_started, - prefix=page, lane=lane) + gate_first=first, prefix=page, lane=lane) isolation = analyze(stopped_prefix(page)) self.record['pre_request_isolation'] = isolation assert isolation['result'] == 'passed', isolation @@ -142,6 +155,8 @@ def inject(self, trace, initial, pending): assert 0 <= requested - status_started <= 250_000_000, 'stale held-input status' assert 0 <= requested - self.record['ready']['observed_ns'] < 2_500_000_000, 'fixture ready window expired' self.record['requested_ns'] = requested + if motion is not None or pointer_cleanup(self.config) == 'retained_inert': + verify_held_gate(self.record) self.deadline_ns = requested + LOCK_MS * 1_000_000 self.record['deadline_ns'] = self.deadline_ns self.requested = True # A lost acknowledgement may still mean lock ownership. @@ -150,7 +165,7 @@ def inject(self, trace, initial, pending): self.record['ack'] = self.event('locked') assert requested <= self.record['ack']['observed_ns'] < self.deadline_ns self.record.update(after=production_status(self.config), observed_ns=time.monotonic_ns()) - cancelled_status(gate, self.record['after']) + cancelled_status(gate, self.record['after'], pointer_cleanup(self.config), lane) self.locked() self.record['result'] = 'acknowledged' return lane @@ -186,6 +201,9 @@ def transition_evidence(page, *, stopped=False): def verify_cancelled(boundary, record, action): assert record['result'] == 'acknowledged' + policy = pointer_cleanup(record) + if policy == 'retained_inert' or 'min_motion_px' in record: + verify_held_gate(record) prefix, lane = record['prefix'], record['lane'] assert set(active_drags(prefix)) == {lane} assert all(row[5] in (0, lane) for row in prefix['events']), 'unowned synthetic activity' @@ -210,11 +228,13 @@ def verify_cancelled(boundary, record, action): 'pointer_motion', 'pointer_enter') for row in synthetic), 'extra action or false completion' assert not any(row[2] in ('pointer_motion', 'pointer_enter') and row[0] > cancel[0] for row in synthetic), 'input continued after cancellation' + if policy == 'retained_inert': + assert not any(row[2] in ('pointer_enter', 'pointer_leave') for row in synthetic), 'retained pointer left or retargeted' releases = [row for row in synthetic if row[2] == 'pointer_button'] assert len(releases) == 1 and releases[0][6] == 0 and releases[0][0] > cancel[0], 'missing own-seat release' assert releases[0][1] <= record['observed_ns'] < deadline, 'release was not observed before cleared lock status' released_synthetic_input(boundary) - cancelled_status(record['gate_status'], record['after']) + cancelled_status(record['gate_status'], record['after'], policy, lane) # A transport-unknown response cannot establish the production reason. assert action['outcome'] == 'response' and action['replayed'] is False, 'unknown drag outcome; no replay' check_response(action['response'], {'kind': 'partial'}) @@ -272,11 +292,12 @@ def start_trace(): save('plan.json', plan) validate_plan(plan) fixture = ActiveLockFixture(plan, args) + policy = pointer_cleanup(fixture.config) origin = provenance(args, plan) for name in (Path(__file__).name, 'production_active_lock_proof_test.py', 'production_lock_refusal_proof.py', 'production_lock_refusal_proof_test.py', 'session_lock_fixture.c', 'production_session_fault_proof.py', - 'production_cancel_proof.py', 'desktop_faults.py'): + 'production_desktop_fault_proof.py', 'production_cancel_proof.py', 'desktop_faults.py'): path = Path(__file__).with_name(name) origin['files'][name] = {'path': str(path.resolve()), 'sha256': hashlib.sha256(path.read_bytes()).hexdigest()} origin['lock_fixture'] = plan['lock_fixture'] @@ -305,7 +326,7 @@ def start_trace(): # Do not close the actor to manufacture a release before this oracle. close_owned(actor) settle_locked(fixture) - stable_status(fixture.record['after'], production_status(fixture.config)) + stable_status(fixture.record['after'], production_status(fixture.config), policy=policy) quiet = trace.collect() save('locked-quiescent-prefix.json', quiet) assert not any(row[5] in (1, 2) for row in trace_interval(boundary, quiet)), 'input after cancellation' @@ -340,7 +361,7 @@ def start_trace(): setup.update(after=primary, observed_ns=time.monotonic_ns()) save('foreground-setup.json', setup) fixture.check_targets() - stable_status(restoration['after'], production_status(fixture.config)) + stable_status(restoration['after'], production_status(fixture.config), policy=policy) phase = 'recovery' initial = start_trace() fresh = launch('recovery') @@ -380,7 +401,8 @@ def start_trace(): fixture.check_targets() final = production_status(fixture.config) save('final-status.json', final) - old, new = lanes(restoration['after'], cleared=True), lanes(final, cleared=True, allow_passive=True) + old = lanes(restoration['after'], cleared=True, allow_passive=policy == 'retained_inert') + new = lanes(final, cleared=True, allow_passive=True) assert set(old) == set(new) == {0, 1} assert sum(new[k]['dispatches'] - old[k]['dispatches'] for k in old) == 1 for key in old: diff --git a/libs/cua-driver/hyprland-plugin/tests/production_active_lock_proof_test.py b/libs/cua-driver/hyprland-plugin/tests/production_active_lock_proof_test.py index f2167b4472..afe2e00670 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production_active_lock_proof_test.py +++ b/libs/cua-driver/hyprland-plugin/tests/production_active_lock_proof_test.py @@ -13,7 +13,7 @@ import production_active_lock_proof as proof import production_lock_refusal_proof as settled from production_lock_refusal_proof_test import plan as lock_plan -from production_session_fault_proof_test import ACTIVE, CANCEL, PARTIAL, status, trace +from production_session_fault_proof_test import ACTIVE, CANCEL, PARTIAL, status, trace, retained_status, motion_gate def plan(): @@ -348,12 +348,15 @@ def test_invalid_plan_retained_without_native_setup(self): self.assertEqual(report['result'], 'failed') self.assertEqual(report['continuous_isolation_across_transitions'], 'unproven') - def episode(self, failure=None): + def episode(self, failure=None, *, retained=False): """Synthetic run ordering only; all native/Driver boundaries are mocked.""" with tempfile.TemporaryDirectory() as directory, ExitStack() as stack: root = Path(directory) path = root / 'plan.json' - path.write_text(json.dumps(plan())) + selected_plan = plan() + if retained: + selected_plan['fault'].update(pointer_cleanup='retained_inert', min_motion_px=12) + path.write_text(json.dumps(selected_plan)) args = SimpleNamespace(plan=path, evidence=root / 'evidence', driver=root / 'driver', trace_socket=root / 'trace', foreground_journal=root / 'journal', primary_grab=root / 'grab') events = [] @@ -378,9 +381,24 @@ def restore(*, cleanup=False): return {'result': 'restored', 'after': status(3)} fixture.restore.side_effect = restore initial, boundary = trace([ACTIVE[0]]), trace(CANCEL) + if retained: + fixture.config = {'pointer_cleanup': 'retained_inert', 'min_motion_px': 12} + boundary = motion_gate(fixture.record) + def retained_restore(*, cleanup=False): + result = restore(cleanup=cleanup) + result['after'] = retained_status(3) + return result + fixture.restore.side_effect = retained_restore if failure == 'release': boundary = trace(ACTIVE + [(8, 'agent_cancel', 1, 0)]) transition = proof.stopped_prefix(trace(CANCEL + [(14, 'keyboard_focus', 0, 0)])) + if retained: + transition_page = deepcopy(boundary) + transition_page['events'].append([9, 14_000_000, 'keyboard_focus', 100, 100, 0, 0]) + if failure == 'unlock_leave': + transition_page['events'].append([10, 15_000_000, 'pointer_leave', 100, 100, 1, 0]) + transition_page['count'] = len(transition_page['events']) + transition = proof.stopped_prefix(transition_page) recovery = trace([(0, 'start', 0, 0), (1, 'agent_admitted', 1, 0), (2, 'pointer_button', 1, 1), (3, 'pointer_button', 1, 0), (4, 'agent_action_end', 1, 0)]) trace_client = Mock() @@ -413,6 +431,11 @@ def submit(fn, *args): def status_read(*_args): if len(clients) == 3: return final + if retained: + value = retained_status(3 if 'explicit_unlock' in events else 2) + if failure == 'lost_presence': + value['input']['lanes'][0]['pointer_focus'] = False + return value return status(3 if 'explicit_unlock' in events else 2) snapshot = {'window_bounds': {'x': 10, 'y': 20, 'width': 800, 'height': 600}, 'proof_image': 'fresh.png', 'proof_observation_started_ns': 10} @@ -484,5 +507,96 @@ def test_lost_trace_start_ack_is_stopped_and_retained_without_dispatch(self): self.assertIn('failed-transition-trace.json', evidence) +class RetainedPointerTests(unittest.TestCase): + def test_full_retained_episode_checks_unlock_and_pre_recovery_presence(self): + result, evidence, events, clients = RunTests().episode(retained=True) + self.assertEqual(result, 0, evidence['result.json']) + self.assertEqual(evidence['result.json']['continuous_isolation_across_transitions'], 'unproven') + self.assertLess(events.index('explicit_unlock'), events.index('launch_50')) + for failure in ('unlock_leave', 'lost_presence'): + result, evidence, events, clients = RunTests().episode(failure, retained=True) + with self.subTest(failure=failure): + self.assertEqual(result, 1) + self.assertNotIn('launch_50', events) + self.assertEqual(evidence['result.json']['recovery']['result'], 'unproven') + + def test_active_lock_accepts_opt_in_motion_and_cleanup_only(self): + value = plan() + value['fault'].update(pointer_cleanup='retained_inert', min_motion_px=12.5) + proof.validate_plan(value) + for change in ({'pointer_cleanup': 'unknown'}, {'extra': True}, + *({'min_motion_px': v} for v in (None, True, 0, -1, float('inf'), float('nan')))): + bad = deepcopy(value) + bad['fault'].update(change) + with self.subTest(change=change), self.assertRaises(AssertionError): + proof.validate_plan(bad) + + def test_retained_cancellation_both_lanes_is_not_transition_isolation(self): + for lane in (1, 2): + value = record() + boundary = motion_gate(value, lane) + result = proof.verify_cancelled(boundary, value, action()) + self.assertEqual(result['result'], 'verified') + self.assertEqual(result['continuous_primary_isolation'], 'unproven') + with self.assertRaises(AssertionError): + proof.cancelled_status(value['gate_status'], value['after']) + for key, replacement in (('pointer_focus', False), ('reserved', True), ('held_keys', 1), + ('held_button', 272), ('drag_active', True), ('lease_active', True), + ('keyboard_focus', True), ('dispatches', 1)): + bad = deepcopy(value) + bad['after']['input']['lanes'][lane - 1][key] = replacement + with self.subTest(lane=lane, key=key), self.assertRaises(AssertionError): + proof.verify_cancelled(boundary, bad, action()) + + def test_motion_and_retention_cannot_accept_pointer_leave_reentry_or_stale_status(self): + value = record() + boundary = motion_gate(value) + for kind in ('pointer_leave', 'pointer_enter', 'pointer_motion'): + bad = deepcopy(boundary) + bad['events'].append([9, 10_000_000, kind, 100, 100, 1, 0]) + bad['count'] += 1 + with self.subTest(kind=kind), self.assertRaises(AssertionError): + proof.verify_cancelled(bad, value, action()) + for change in ({'min_motion_px': 14}, {'status_started_ns': -300_000_000}, {'lane': 2}): + with self.subTest(change=change), self.assertRaises(AssertionError): + proof.verify_cancelled(boundary, {**value, **change}, action()) + + def test_motion_poll_preserves_short_ready_window_and_brackets_status(self): + for failure in (None, 'insufficient', 'lane', 'stale'): + fixture = FixtureTests().fixture() + fixture.config.update(pointer_cleanup='retained_inert', min_motion_px=12) + fixture.record.update(pointer_cleanup='retained_inert', min_motion_px=12) + value = record() + motion_gate(value) + first, page = deepcopy(value['prefix']), deepcopy(value['prefix']) + if failure == 'insufficient': + first['events'][-1][7] = page['events'][-1][7] = 15 + if failure == 'lane': + for row in page['events'][1:]: + row[5] = 2 + calls = [] + def poll(*args, **kwargs): + calls.append(('trace', kwargs['timeout'])) + result = first if len(calls) == 1 else page + return result, proof.active_drags(result) + def read(*args): + calls.append(('status', None)) + return value['gate_status'] if len(calls) == 2 else retained_status() + with self.subTest(failure=failure), patch.object(proof, 'power'), \ + patch.object(proof, 'poll_fault_active', side_effect=poll), \ + patch.object(proof, 'production_status', side_effect=read), \ + patch.object(proof.time, 'monotonic_ns', side_effect=[-300_000_000 if failure == 'stale' else 5_000_000, 6_000_000, 12_000_000]): + if failure: + with self.assertRaises(AssertionError): + fixture.inject(Mock(), trace([ACTIVE[0]]), Mock(done=Mock(return_value=False))) + self.assertFalse(fixture.requested) + self.assertEqual(fixture.child.stdin.getvalue(), b'') + else: + self.assertEqual(fixture.inject(Mock(), trace([ACTIVE[0]]), Mock(done=Mock(return_value=False))), 1) + self.assertEqual(fixture.child.stdin.getvalue(), b'LOCK\n') + self.assertEqual(calls[:3], [('trace', 1), ('status', None), ('trace', 0.25)]) + self.assertEqual(fixture.deadline_ns - fixture.record['requested_ns'], 20_000_000_000) + + if __name__ == '__main__': unittest.main() diff --git a/libs/cua-driver/hyprland-plugin/tests/production_desktop_fault_proof.py b/libs/cua-driver/hyprland-plugin/tests/production_desktop_fault_proof.py index 454d61e8a0..206e4631c5 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production_desktop_fault_proof.py +++ b/libs/cua-driver/hyprland-plugin/tests/production_desktop_fault_proof.py @@ -567,7 +567,7 @@ def verify_layout_refusal(record): before, after = keymap_lanes(record['before'], cleared=policy == 'cleared'), keymap_lanes(record['after'], cleared=policy == 'cleared') if policy == 'retained_inert': verify_retained_inert(record['before'], record['before'], record['lane']) - verify_retained_inert(record['before'], record['after'], record['lane']) + verify_refusal_claim(record['before'], record['after'], record['response'], record['lane']) verify_target_snapshots(record['target'], record['bounds'], record['snapshot'], record['after_snapshot']) for lane in before: assert all(before[lane][key] == after[lane][key] for key in ('epoch', 'desktop_generation', 'dispatches')), \ @@ -576,10 +576,48 @@ def verify_layout_refusal(record): tail = trace_interval(record['trace_before'], record['trace_after']) assert not any(row[5] in (1, 2) for row in tail), 'refused fresh action dispatched synthetic input' assert analyze(stopped_prefix(record['trace_after']))['result'] == 'passed' + if policy == 'retained_inert': + verify_refusal_closed(record, record['lane']) return {'result': 'verified', 'reason': 'unsupported_layout', 'no_dispatch': 'verified', 'compiled_map_invalidation': 'verified', 'keymap_hash': 'not_exposed'} +def verify_refusal_claim(before, after, response, interrupted_lane=None): + """A fresh CLAIM reserves capacity, never TARGET authority or inherited hover. + + This exception is only for the fresh refusal probe between its action and + EOF. Fault cancellation and post-EOF checks still require no reservation. + The response identifies the claimed lane; no status owner PID is exposed. + """ + old, new = idle_lanes(before), keymap_lanes(after) + claimed = response['structuredContent'].get('lane') + assert type(claimed) is int and claimed in (0, 1), 'refusal has no exact claimed lane' + if interrupted_lane is not None: + assert type(interrupted_lane) is int and interrupted_lane in (1, 2) + assert old[interrupted_lane - 1]['pointer_focus'] is True + for lane, row in new.items(): + assert type(old[lane].get('pointer_focus')) is bool + assert row.get('pointer_focus') is old[lane]['pointer_focus'], 'refusal changed pointer presence' + assert row.get('reserved') is (lane == claimed), 'unexpected refusal reservation' + assert all(type(row.get(k)) is int and row[k] == 0 for k in ('held_button', 'held_keys')) + assert all(row.get(k) is False for k in ('lease_active', 'drag_active', 'keyboard_focus')), 'refused CLAIM gained input authority' + assert all(row[k] == old[lane][k] for k in ('epoch', 'desktop_generation', 'dispatches')), 'refusal changed desktop or dispatched' + return {'claimed_lane': claimed, 'capacity_only': True, 'input_authority': False, + 'owner_pid_in_status': 'not_exposed'} + + +def verify_refusal_closed(record, interrupted_lane): + closed = record['closure'] + assert closed['runtime_pid'] == record['runtime_pid'] and type(closed['exit_code']) is int + assert record['observed_ns'] <= closed['started_ns'] <= closed['reaped_ns'] <= closed['observed_ns'] + verify_retained_inert(record['before'], closed['status'], interrupted_lane) + old, new = keymap_lanes(record['after']), keymap_lanes(closed['status']) + for lane in old: + assert all(old[lane][k] == new[lane][k] for k in ('epoch', 'desktop_generation', 'dispatches')) + assert not any(row[5] in (1, 2) for row in trace_interval(record['trace_after'], closed['trace'])), 'probe EOF changed inert pointer or emitted input' + return {'result': 'verified', 'reservation_released': True} + + def refuse_new_action(client, observer, victim, spec, stage, trace, config, guard, save): """Exercise real Driver admission while the compiled physical map is invalid.""" assert victim.process.poll() is not None, 'old runtime must be reaped before refusal probe' @@ -621,6 +659,14 @@ def refuse_new_action(client, observer, victim, spec, stage, trace, config, guar record['observed_ns'] = time.monotonic_ns() assert record['observed_ns'] < config['deadline_ns'], 'watchdog restored during refusal probe' guard() + if policy == 'retained_inert': + record['claim'] = verify_refusal_claim(record['before'], record['after'], record['response'], record['lane']) + closed = record['closure'] = {'runtime_pid': client.process.pid, 'started_ns': time.monotonic_ns()} + close_owned(client) + closed.update(reaped_ns=time.monotonic_ns(), exit_code=client.process.poll()) + closed.update(status=production_status(config['instance'], True), trace=trace.collect(), observed_ns=time.monotonic_ns()) + assert closed['observed_ns'] < config['deadline_ns'], 'watchdog restored during probe closure' + guard() record['verification'] = verify_layout_refusal(record) return record finally: @@ -667,6 +713,7 @@ def verify_fault(boundary, record, restoration, action): if policy == 'retained_inert': assert refusal['lane'] == record['lane'] and refusal['target'] == record['target'] and refusal['bounds'] == record['bounds'] verify_retained_inert(record['after'], refusal['before'], record['lane']) + assert refusal['closure']['observed_ns'] < restoration['started_ns'], 'probe not closed before restoration' verify_layout_refusal(refusal) after, before_refusal = keymap_lanes(record['after']), keymap_lanes(refusal['before']) for lane in after: diff --git a/libs/cua-driver/hyprland-plugin/tests/production_desktop_fault_proof_test.py b/libs/cua-driver/hyprland-plugin/tests/production_desktop_fault_proof_test.py index 8466e0117f..7b96627c8e 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production_desktop_fault_proof_test.py +++ b/libs/cua-driver/hyprland-plugin/tests/production_desktop_fault_proof_test.py @@ -120,6 +120,11 @@ def retained_evidence(kind='keymap'): after_snapshot=target_snapshot('refusal-after', runtime=101, observed_ns=10_700_000), before=deepcopy(candidate['after']), after=deepcopy(candidate['after']), trace_before=deepcopy(boundary), trace_after=deepcopy(boundary)) + refused['response']['structuredContent']['lane'] = 0 + refused['after']['input']['lanes'][0]['reserved'] = True + refused['closure'] = {'runtime_pid': refused['runtime_pid'], 'exit_code': 0, + 'started_ns': 11_100_000, 'reaped_ns': 11_200_000, 'observed_ns': 11_300_000, + 'status': deepcopy(refused['before']), 'trace': deepcopy(boundary)} else: candidate['after']['configured'] = False candidate['after']['transport']['ready'] = False @@ -172,11 +177,12 @@ def test_fresh_refusal_probe_threads_retained_policy_and_exact_target(self): boundary, candidate, _ = retained_evidence() spec = plan('keymap')['agents'][0] fresh, observer, victim = client(103), client(101), client(100, alive=False) - fresh.tool.side_effect = [{}, layout_refusal()['response']] + fresh.tool.side_effect = [{}, candidate['wrong_layout']['response']] before = deepcopy(candidate['after']) if missing_focus: before['input']['lanes'][0]['pointer_focus'] = False - stack.enter_context(patch.object(proof, 'production_status', side_effect=[before, candidate['after']])) + stack.enter_context(patch.object(proof, 'production_status', side_effect=[before, candidate['wrong_layout']['after'], candidate['after']])) + stack.enter_context(patch.object(proof, 'close_owned', side_effect=lambda c: setattr(c.process.poll, 'return_value', 0))) stack.enter_context(patch.object(proof, 'app_process_identity')) stack.enter_context(patch.object(proof, 'grounded_snapshot', side_effect=[ {**target_snapshot('refusal-before', runtime=103, observed_ns=10_200_000), 'proof_image': '/synthetic/image'}, @@ -186,7 +192,7 @@ def test_fresh_refusal_probe_threads_retained_policy_and_exact_target(self): stack.enter_context(patch.object(proof, 'keymap_options', return_value=options(False))) stack.enter_context(patch.object(proof, '_guard')) stack.enter_context(patch.object(proof, 'file_identity', return_value={'inode': 20})) - stack.enter_context(patch.object(proof.time, 'monotonic_ns', side_effect=[10_100_000, 10_500_000, 11_000_000])) + stack.enter_context(patch.object(proof.time, 'monotonic_ns', side_effect=[10_100_000, 10_500_000, 11_000_000, 11_100_000, 11_200_000, 11_300_000])) config = {'instance': 'exact', 'path': '/unused', 'deadline_ns': 12_000_000_000, 'pointer_cleanup': 'retained_inert', 'lane': 1, 'files': {'disabled': {'identity': {'inode': 20}}}} @@ -232,6 +238,8 @@ def test_every_cleanup_status_requires_inert_authority_and_exact_pointer_presenc stages = ['after', 'restored', 'target_status'] + (['refusal-before', 'refusal-after'] if kind == 'keymap' else []) for stage in stages: for key, value in fields: + if stage == 'refusal-after' and key == 'reserved' and value is True: + value = False # This one fresh CLAIM must exist until EOF. boundary, candidate, restored = retained_evidence(kind) observed = (restored['status'] if stage == 'restored' else candidate['wrong_layout'][stage.split('-')[1]] if stage.startswith('refusal-') else candidate[stage]) @@ -243,6 +251,46 @@ def test_every_cleanup_status_requires_inert_authority_and_exact_pointer_presenc with self.assertRaisesRegex(AssertionError, 'presence'): proof.verify_fault(boundary, candidate, restored, action()) + def test_fresh_claim_is_capacity_only_and_must_disappear_on_probe_eof(self): + for claimed in (0, 1): + _, candidate, _ = retained_evidence() + record = candidate['wrong_layout'] + record['response']['structuredContent']['lane'] = claimed + for row in record['after']['input']['lanes']: + row['reserved'] = row['lane'] == claimed + self.assertEqual(proof.verify_layout_refusal(record)['result'], 'verified') + for side, lane, key, value in ( + ('before', claimed, 'reserved', True), ('after', 1 - claimed, 'reserved', True), + ('after', claimed, 'lease_active', True), ('after', claimed, 'held_keys', 1), + ('after', claimed, 'held_button', 272), ('after', claimed, 'keyboard_focus', True), + ('after', claimed, 'drag_active', True), ('after', claimed, 'dispatches', 99), + ('closure', claimed, 'reserved', True), ('closure', 0, 'pointer_focus', False)): + bad = deepcopy(record) + status = bad['closure']['status'] if side == 'closure' else bad[side] + status['input']['lanes'][lane][key] = value + with self.subTest(claimed=claimed, side=side, key=key), self.assertRaises(AssertionError): + proof.verify_layout_refusal(bad) + _, candidate, _ = retained_evidence() + record = candidate['wrong_layout'] + for bad_lane in (None, True, -1, 2, '0'): + bad = deepcopy(record) + bad['response']['structuredContent']['lane'] = bad_lane + with self.subTest(lane=bad_lane), self.assertRaises(AssertionError): + proof.verify_layout_refusal(bad) + for key, value in (('runtime_pid', record['runtime_pid'] + 1), ('exit_code', None), + ('reaped_ns', 1), ('observed_ns', 1)): + bad = deepcopy(record) + bad['closure'][key] = value + with self.subTest(key=key), self.assertRaises(AssertionError): + proof.verify_layout_refusal(bad) + for event in ('pointer_leave', 'pointer_enter', 'pointer_motion', 'agent_admitted', 'pointer_button'): + bad = deepcopy(record) + page = bad['closure']['trace'] + page['events'].append([9, 11_250_000, event, 100, 100, 1, 0]) + page['count'] += 1 + with self.subTest(event=event), self.assertRaises(AssertionError): + proof.verify_layout_refusal(bad) + def test_retained_trace_cannot_hide_leave_retarget_motion_new_input_or_missing_release(self): for kind in ('config_disable', 'keymap'): for event in ('pointer_leave', 'pointer_enter', 'pointer_motion', 'keyboard_enter', diff --git a/libs/cua-driver/hyprland-plugin/tests/production_lock_refusal_proof.py b/libs/cua-driver/hyprland-plugin/tests/production_lock_refusal_proof.py index 0a0c20bdb0..0d8a4ce836 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production_lock_refusal_proof.py +++ b/libs/cua-driver/hyprland-plugin/tests/production_lock_refusal_proof.py @@ -35,12 +35,13 @@ from production_cancel_proof import (MAX_GROUNDING_AGE_NS, PROFILE, close_owned, grounded_snapshot, stopped_prefix, verify_recovery_cleanup, verify_recovery_trace) from production_mcp import DirectMCP, assert_distinct_runtimes, stop_process -from production_desktop_fault_proof import idle_lanes +from production_desktop_fault_proof import idle_lanes, pointer_cleanup import production_pointer_grounding as pointer_grounding from production_realapp_proof import (PRIMARY_LIFETIME_MS, capacity_lane, check_response, primary_acknowledgement, provenance, require_primary_active, trace_interval) from production_session_fault_proof import (PROCESS_KEYS, SessionFault, connect_trace, - guard_guest, lanes, power, production_status, validate_plan as session_plan) + guard_guest, lanes, power, production_status, validate_plan as session_plan, + validate_fault_options) from realapp_proof import cleanup_all, released_synthetic_input @@ -50,8 +51,9 @@ def validate_plan(plan): - assert plan['purpose'] == 'lock_refusal' and plan['fault'] == {'kind': 'lock'} - session_plan({**plan, 'purpose': 'session_fault', 'fault': {'kind': 'dpms'}}) + assert plan['purpose'] == 'lock_refusal' and plan['fault']['kind'] == 'lock' + validate_fault_options(plan['fault'], motion=False) + session_plan({**plan, 'purpose': 'session_fault', 'fault': {**plan['fault'], 'kind': 'dpms'}}) fixture = plan['lock_fixture'] assert set(fixture) == {'path', 'device', 'inode', 'uid', 'sha256', 'source_sha256'} assert Path(fixture['path']).is_absolute() and Path(fixture['path']).name == 'session_lock_fixture' @@ -62,12 +64,16 @@ def validate_plan(plan): assert len({plan['compositor']['pid'], *(p['pid'] for p in plan['identities'].values())}) == 3 -def stable_status(before, after, *, advanced=False): - # Only a pre-transition idle baseline may contain an inert pointer. - old = idle_lanes(before) if advanced else lanes(before, cleared=True) - new = lanes(after, cleared=True) +def stable_status(before, after, *, advanced=False, policy='cleared'): + # By default only a pre-transition baseline may contain an inert pointer. + pointer_cleanup({'pointer_cleanup': policy}) + retained = policy == 'retained_inert' + old = idle_lanes(before) if advanced or retained else lanes(before, cleared=True) + new = idle_lanes(after) if retained else lanes(after, cleared=True) assert set(old) == set(new) for lane in old: + if retained: + assert old[lane]['pointer_focus'] is new[lane]['pointer_focus'], 'pointer presence changed' assert old[lane].get('reserved') is False and new[lane].get('reserved') is False assert old[lane]['epoch'] == new[lane]['epoch'], 'compositor lane replaced' assert old[lane]['dispatches'] == new[lane]['dispatches'], 'unexpected dispatch' @@ -92,7 +98,7 @@ def sample(): nonlocal stable_since, previous fixture.locked() current = {'primary': wm(), 'status': production_status(fixture.config)} - stable_status(fixture.record['after'], current['status']) + stable_status(fixture.record['after'], current['status'], policy=pointer_cleanup(fixture.config)) now = time.monotonic_ns() samples.append({**current, 'observed_ns': now}) if current != previous: @@ -113,6 +119,9 @@ def __init__(self, plan, args): self.buffer = b'' self.events = [] self.record = {'result': 'unproven', 'events': self.events} + for key in ('pointer_cleanup', 'min_motion_px'): + if key in plan['fault']: + self.config[key] = self.record[key] = plan['fault'][key] self.requested = False self.restored = False self.check_targets() @@ -192,7 +201,7 @@ def lock(self): self.record['ack'] = self.event('locked') assert self.record['requested_ns'] <= self.record['ack']['observed_ns'] < self.deadline_ns self.record['after'] = production_status(self.config) - stable_status(self.record['before'], self.record['after'], advanced=True) + stable_status(self.record['before'], self.record['after'], advanced=True, policy=pointer_cleanup(self.config)) self.locked() self.record['result'] = 'acknowledged' @@ -238,7 +247,7 @@ def restore(self, *, cleanup=False): record.update(after=production_status(self.config), observed_ns=time.monotonic_ns()) if not cleanup: assert self.requested and record['started_ns'] <= record['ack']['observed_ns'] < self.deadline_ns - stable_status(self.record['after'], record['after'], advanced=True) + stable_status(self.record['after'], record['after'], advanced=True, policy=pointer_cleanup(self.config)) record['result'] = 'restored' return record @@ -248,7 +257,7 @@ def verify_refusal(record): check_response(record['response'], {'kind': 'refused', 'reason': 'session_unavailable'}) assert record['prepared_ns'] <= record['lock_ack_ns'] <= record['runtime_started_ns'] <= record['dispatch_ns'] <= record['observed_ns'] < record['deadline_ns'] assert record['dispatch_ns'] - record['prepared_ns'] <= MAX_GROUNDING_AGE_NS - stable_status(record['before'], record['after']) + stable_status(record['before'], record['after'], policy=pointer_cleanup(record)) assert not any(row[5] in (1, 2) for row in trace_interval(record['trace_before'], record['trace_after'])), 'refused action emitted synthetic events' isolation = analyze(stopped_prefix(record['trace_after'])) assert isolation['result'] == 'passed', isolation @@ -264,6 +273,17 @@ def verify_refusal_cleanup(prefix, stopped): return isolation +def verify_inert_transition(initial, stopped): + """Retain raw lock analysis without claiming primary transition isolation.""" + analysis = analyze(stopped) + assert analysis.get('telemetry_complete') is True, analysis + trace_interval(initial, initial) + assert stopped['events'][:initial['count']] == initial['events'], 'transition trace history changed' + assert not any(row[5] in (1, 2) for row in stopped['events'][initial['count']:]), 'synthetic activity while pointer must remain inert' + return {'continuous_primary_isolation': 'unproven', 'raw_primary_analysis': analysis, + 'synthetic_pointer_continuity': 'verified'} + + def click_once(client, arguments, record, save, name): """Persist the attempt before transport; an exception never permits replay.""" assert record['outcome'] == 'unknown' and record['replayed'] is False @@ -357,7 +377,8 @@ def start_trace(): fixture = LockFixture(plan, args) origin = provenance(args, plan) for name in (Path(__file__).name, 'production_lock_refusal_proof_test.py', 'session_lock_fixture.c', - 'production_session_fault_proof.py', 'desktop_faults.py', 'production_cancel_proof.py'): + 'production_session_fault_proof.py', 'production_desktop_fault_proof.py', + 'desktop_faults.py', 'production_cancel_proof.py'): path = Path(__file__).with_name(name) origin['files'][name] = {'path': str(path.resolve()), 'sha256': hashlib.sha256(path.read_bytes()).hexdigest()} origin['lock_fixture'] = plan['lock_fixture'] @@ -368,13 +389,23 @@ def start_trace(): assert state(args.foreground_journal)['held'] is False, 'lock setup requires released primary fixture' probe = prepare_refusal_click(observer, spec, save) arguments, prepared_ns = probe['arguments'], probe['prepared_ns'] + policy = pointer_cleanup(fixture.config) + if policy == 'retained_inert': + lock_initial = start_trace() fixture.lock() settle_locked(fixture) + if policy == 'retained_inert': + trace.exchange('TRACE_STOP') + tracing = False + lock_stopped = trace.collect() + save('lock-transition-trace.json', lock_stopped) + save('lock-transition-analysis.json', verify_inert_transition(lock_initial, lock_stopped)) locked_primary, locked_foreground = wm(), state(args.foreground_journal) refusal = report['refusal'] = {**probe, 'outcome': 'unknown', 'replayed': False, + 'pointer_cleanup': policy, 'lock_ack_ns': fixture.record['ack']['observed_ns'], 'deadline_ns': fixture.deadline_ns, 'before': production_status(fixture.config)} - stable_status(fixture.record['after'], refusal['before']) + stable_status(fixture.record['after'], refusal['before'], policy=policy) refusal['trace_before'] = start_trace() tracing = True refusal['runtime_started_ns'] = time.monotonic_ns() @@ -390,10 +421,16 @@ def start_trace(): fixture.locked() refusal.update(after=production_status(fixture.config), trace_after=trace.collect(), observed_ns=time.monotonic_ns()) refusal['verification'] = verify_refusal(refusal) - trace.exchange('TRACE_STOP') - tracing = False - stopped = trace.collect() - save('refusal-trace.json', stopped) + if policy == 'retained_inert': + # Keep the same trace running through readback and graceful unlock. + # The strict settled prefix is separate from raw transition analysis. + stopped = stopped_prefix(trace.collect()) + else: + trace.exchange('TRACE_STOP') + tracing = False + stopped = trace.collect() + save('refusal-trace-prefix.json' if policy == 'retained_inert' else 'refusal-trace.json', stopped) + refusal['analysis_uses_end_sentinel'] = policy == 'retained_inert' refusal['isolation'] = verify_refusal_cleanup(refusal['trace_after'], stopped) refusal['primary_readback'] = {'before': locked_primary, 'after': wm(), 'foreground_before': locked_foreground, 'foreground_after': state(args.foreground_journal)} @@ -403,6 +440,12 @@ def start_trace(): save('refusal.json', refusal) restoration = fixture.restore() save('restoration.json', restoration) + if policy == 'retained_inert': + trace.exchange('TRACE_STOP') + tracing = False + unlock_stopped = trace.collect() + save('unlock-transition-trace.json', unlock_stopped) + save('unlock-transition-analysis.json', verify_inert_transition(refusal['trace_before'], unlock_stopped)) fixture.check_targets() power(fixture.config, True) # All intentional focus/cursor/grab setup precedes the recovery trace. @@ -426,7 +469,7 @@ def start_trace(): setup.update(after=primary, observed_ns=time.monotonic_ns()) save('foreground-setup.json', setup) fixture.check_targets() - stable_status(restoration['after'], production_status(fixture.config)) + stable_status(restoration['after'], production_status(fixture.config), policy=policy) initial = start_trace() tracing = True save('recovery-trace-initial.json', initial) @@ -469,7 +512,8 @@ def start_trace(): fixture.check_targets() final_status = production_status(fixture.config) # Exactly the one completed recovery click may advance dispatch counts. - old, new = lanes(restoration['after'], cleared=True), lanes(final_status, cleared=True, allow_passive=True) + old = lanes(restoration['after'], cleared=True, allow_passive=policy == 'retained_inert') + new = lanes(final_status, cleared=True, allow_passive=True) assert sum(new[k]['dispatches'] - old[k]['dispatches'] for k in old) == 1 for key in old: assert new[key]['dispatches'] >= old[key]['dispatches'] and new[key].get('reserved') is False diff --git a/libs/cua-driver/hyprland-plugin/tests/production_lock_refusal_proof_test.py b/libs/cua-driver/hyprland-plugin/tests/production_lock_refusal_proof_test.py index b5113edde7..ed78e2aeb7 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production_lock_refusal_proof_test.py +++ b/libs/cua-driver/hyprland-plugin/tests/production_lock_refusal_proof_test.py @@ -12,7 +12,7 @@ from unittest.mock import Mock, patch import production_lock_refusal_proof as proof -from production_session_fault_proof_test import plan as session_plan, status, trace, REFUSED +from production_session_fault_proof_test import plan as session_plan, status, trace, REFUSED, retained_status def plan(): @@ -505,5 +505,98 @@ def test_partial_event_never_blocks_on_readline(self): fixture.child.stdout.readline.assert_not_called() +class RetainedPointerTests(unittest.TestCase): + def test_settled_lock_accepts_only_cleanup_option_not_a_motion_claim(self): + value = plan() + value['fault']['pointer_cleanup'] = 'retained_inert' + proof.validate_plan(value) + for change in ({'pointer_cleanup': 'unknown'}, {'min_motion_px': 12}, {'kill_to_unlock': True}): + bad = deepcopy(value) + bad['fault'].update(change) + with self.subTest(change=change), self.assertRaises(AssertionError): + proof.validate_plan(bad) + + def test_stable_refusal_and_restoration_preserve_both_lane_presence(self): + for lane in (1, 2): + before, after = retained_status(1, lane), retained_status(2, lane) + proof.stable_status(before, after, advanced=True, policy='retained_inert') + proof.stable_status(after, deepcopy(after), policy='retained_inert') + with self.assertRaises(AssertionError): + proof.stable_status(before, after, advanced=True) + for index, key, value in ((lane - 1, 'pointer_focus', False), (2 - lane, 'pointer_focus', True), + (lane - 1, 'reserved', True), (lane - 1, 'held_button', 272), + (lane - 1, 'held_keys', 1), (lane - 1, 'drag_active', True), + (lane - 1, 'lease_active', True), (lane - 1, 'keyboard_focus', True), + (lane - 1, 'dispatches', 1), (lane - 1, 'epoch', 'different'), + (lane - 1, 'desktop_generation', 1)): + bad = deepcopy(after) + bad['input']['lanes'][index][key] = value + with self.subTest(lane=lane, key=key), self.assertRaises(AssertionError): + proof.stable_status(before, bad, advanced=True, policy='retained_inert') + bad_before = deepcopy(before) + bad_before['input']['lanes'][lane - 1]['reserved'] = True + with self.assertRaises(AssertionError): + proof.stable_status(bad_before, after, advanced=True, policy='retained_inert') + + def test_refusal_does_not_own_input_or_dispatch_with_retained_hover(self): + value = refusal() + value.update(pointer_cleanup='retained_inert', before=retained_status(), after=retained_status()) + self.assertEqual(proof.verify_refusal(value)['result'], 'verified') + for side, key, change in (('before', 'reserved', True), ('after', 'pointer_focus', False), + ('after', 'dispatches', 1), ('after', 'held_keys', 1)): + bad = deepcopy(value) + bad[side]['input']['lanes'][0][key] = change + with self.subTest(side=side, key=key), self.assertRaises(AssertionError): + proof.verify_refusal(bad) + + def test_unlock_continuity_keeps_raw_primary_failure_and_rejects_synthetic_activity(self): + initial = trace([(0, 'start', 0, 0)]) + stopped = proof.stopped_prefix(trace([(0, 'start', 0, 0), (1, 'keyboard_focus', 0, 0)])) + result = proof.verify_inert_transition(initial, stopped) + self.assertEqual(result['continuous_primary_isolation'], 'unproven') + self.assertEqual(result['raw_primary_analysis'], proof.analyze(stopped)) + self.assertNotEqual(result['raw_primary_analysis']['result'], 'passed') + for kind in ('pointer_leave', 'pointer_enter', 'pointer_motion', 'agent_admitted', 'pointer_button'): + bad = proof.stopped_prefix(trace([(0, 'start', 0, 0), (1, kind, 1, 0)])) + with self.subTest(kind=kind), self.assertRaises(AssertionError): + proof.verify_inert_transition(initial, bad) + bad = deepcopy(stopped) + bad['overflow'] = True + with self.assertRaises(AssertionError): + proof.verify_inert_transition(initial, bad) + + def test_shared_settling_and_graceful_restore_thread_policy_without_deadline_change(self): + settling = Mock(config={'pointer_cleanup': 'retained_inert'}, record={'after': retained_status()}) + def sample_twice(fn, timeout): + self.assertEqual(timeout, 2) + self.assertIsNone(fn()) + return fn() + with patch.object(proof, 'wm', return_value={}), \ + patch.object(proof, 'production_status', return_value=retained_status()), \ + patch.object(proof.time, 'monotonic_ns', side_effect=[1, 100_000_001]), \ + patch.object(proof, 'wait_for', side_effect=sample_twice): + self.assertEqual(proof.settle_locked(settling)['status'], retained_status()) + fixture = FixtureTests().fixture() + fixture.config['pointer_cleanup'] = 'retained_inert' + fixture.record['after'] = retained_status(2) + fixture.events = [{'event': 'locked', 'observed_ns': 2}] + fixture.read_event = Mock(return_value={'event': 'unlocked', 'observed_ns': 4}) + fixture.child.wait.return_value = 0 + with patch.object(proof, 'guard_guest'), \ + patch.object(proof, 'production_status', return_value=retained_status(3)), \ + patch.object(proof, 'wait_for', side_effect=lambda fn, timeout: fn()), \ + patch.object(proof.time, 'monotonic_ns', return_value=3): + # Model the event reader's append as in the real helper. + def unlock(): + event = {'event': 'unlocked', 'observed_ns': 4} + fixture.events.append(event) + return event + fixture.read_event.side_effect = unlock + self.assertEqual(fixture.restore()['result'], 'restored') + fixture.child.kill.assert_not_called() + fixture.child.terminate.assert_not_called() + self.assertEqual(proof.LOCK_MS, 20000) + + if __name__ == '__main__': unittest.main() diff --git a/libs/cua-driver/hyprland-plugin/tests/production_session_fault_proof.py b/libs/cua-driver/hyprland-plugin/tests/production_session_fault_proof.py index 362493ae9b..81f5fae318 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production_session_fault_proof.py +++ b/libs/cua-driver/hyprland-plugin/tests/production_session_fault_proof.py @@ -47,7 +47,9 @@ from primary_trace import Trace, analyze from production_cancel_proof import (MAX_GROUNDING_AGE_NS, PROFILE, active_drags, call_drag, close_owned, grounded_snapshot, poll_active, prepare_drag, stopped_prefix, verify_recovery_cleanup) -from production_desktop_fault_proof import guest_identity, idle_lanes, verify_status +from production_desktop_fault_proof import (guest_identity, idle_lanes, verify_status, + pointer_cleanup, validate_min_motion, drag_motion_px, poll_fault_active, verify_retained_inert, + verify_refusal_claim) from production_geometry_fault_proof import recover, fault_outcome, validate_plan as geometry_plan from production_mcp import DirectMCP, assert_distinct_runtimes, stop_process import production_pointer_grounding as pointer_grounding @@ -62,8 +64,9 @@ def validate_plan(plan): - assert plan['purpose'] == 'session_fault' and plan['fault'] == {'kind': 'dpms'}, \ + assert plan['purpose'] == 'session_fault' and plan['fault']['kind'] == 'dpms', \ 'only DPMS is qualified; session-lock primary transition oracle is unsupported' + validate_fault_options(plan['fault']) bounds = plan['agents'][0]['bounds'] geometry_plan({**plan, 'purpose': 'geometry_fault', 'compositor': {key: plan['compositor'][key] for key in ('pid', 'instance')}, @@ -144,14 +147,64 @@ def lanes(status, cleared=False, *, allow_passive=False): return result -def transition(before, after): - old, new = lanes(before), lanes(after, cleared=True) +def validate_fault_options(fault, *, motion=True): + allowed = {'kind', 'pointer_cleanup'} | ({'min_motion_px'} if motion else set()) + assert {'kind'} <= set(fault) <= allowed, 'unsupported fault option' + pointer_cleanup(fault) + if 'min_motion_px' in fault: + validate_min_motion(fault['min_motion_px']) + + +def transition(before, after, policy='cleared', lane=None): + pointer_cleanup({'pointer_cleanup': policy}) + old, new = lanes(before), lanes(after, cleared=True, allow_passive=policy == 'retained_inert') + if policy == 'retained_inert': + verify_retained_inert(before, after, lane) for lane in old: assert old[lane]['epoch'] == new[lane]['epoch'], 'compositor lane replaced' assert new[lane]['desktop_generation'] > old[lane]['desktop_generation'], 'DPMS did not revoke authority' assert new[lane].get('reserved') is False, 'reservation survived DPMS transition' +def verify_held_gate(record): + """Recheck the opt-in trace/status/trace gate from saved evidence.""" + first, page, lane = record['gate_first'], record['prefix'], record['lane'] + trace_interval(first, page) + assert active_drags(first) == active_drags(page) and set(active_drags(page)) == {lane}, 'held lane changed across status' + synthetic = [row for row in page['events'] if row[5] in (1, 2)] + assert all(row[5] == lane for row in synthetic), 'held gate crossed lanes' + press = next(row for row in synthetic if row[2] == 'pointer_button') + assert not any(row[2] == 'pointer_leave' or (row[2] == 'pointer_enter' and row[0] > press[0]) + for row in synthetic), 'held pointer left or retargeted' + started, requested = record['status_started_ns'], record['requested_ns'] + assert first['events'][-1][1] <= started <= requested + assert 0 <= requested - started <= 250_000_000, 'stale held-input status' + assert 0 <= requested - page['events'][-1][1] <= 250_000_000, 'stale held-input trace' + rows = lanes(record['gate_status']) + for key, row in rows.items(): + assert type(row['held_keys']) is int and row['held_keys'] == 0 + if key == lane - 1: + assert type(row['held_button']) is int and row['held_button'] == 272 + assert all(row.get(k) is True for k in ('drag_active', 'lease_active', 'pointer_focus', 'reserved')) + else: + assert type(row['held_button']) is int and row['held_button'] == 0 + assert all(row.get(k) is False for k in ('drag_active', 'lease_active', 'keyboard_focus', 'reserved')) + if 'min_motion_px' in record: + validate_min_motion(record['min_motion_px']) + assert all(drag_motion_px(p, lane) >= record['min_motion_px'] for p in (first, page)), 'insufficient held motion' + + +def verify_inert_interval(before, after): + assert not any(row[5] in (1, 2) for row in trace_interval(before, after)), 'synthetic activity while pointer must remain inert' + + +def verify_stable_inert(before, after, lane): + old = idle_lanes(before) + new = verify_retained_inert(before, after, lane) + assert all(old[key][field] == row[field] for key, row in new.items() + for field in ('epoch', 'desktop_generation')), 'desktop changed during inert interval' + + @contextmanager def control_lock(config): # One private lock serializes watchdog and controller, including the off IPC. @@ -201,6 +254,9 @@ def __init__(self, plan, args): self.child = self.cancel_fd = None self.mutated = False self.record = {'result': 'unproven', 'kind': 'dpms'} + for key in ('pointer_cleanup', 'min_motion_px'): + if key in plan['fault']: + self.config[key] = self.record[key] = plan['fault'][key] self.check_targets() power(self.config, True) self.record['before'] = production_status(self.config) @@ -261,8 +317,19 @@ def inject(self, trace, initial, pending, guard): with control_lock(self.config): self.check_targets() self.record['monitors_before'] = power(self.config, True) - self.record['gate_status'] = production_status(self.config) - page, active = poll_active(trace, initial, None, [pending]) + gated = 'min_motion_px' in self.config or pointer_cleanup(self.config) == 'retained_inert' + if gated: + gate_deadline = time.monotonic() + 3 + first, _ = poll_fault_active(trace, initial, pending, self.config.get('min_motion_px')) + self.record.update(gate_first=first, status_started_ns=time.monotonic_ns()) + self.record['gate_status'] = production_status(self.config) + remaining = min(0.25, gate_deadline - time.monotonic()) + assert remaining > 0, 'held gate exceeded bounded wait' + page, active = poll_fault_active(trace, first, pending, self.config.get('min_motion_px'), timeout=remaining) + assert time.monotonic() <= gate_deadline, 'held gate exceeded bounded wait' + else: + self.record['gate_status'] = production_status(self.config) + page, active = poll_active(trace, initial, None, [pending]) lane = next(iter(active)) assert set(active) == {lane} guard() @@ -272,6 +339,11 @@ def inject(self, trace, initial, pending, guard): assert 0 <= requested - page['events'][-1][1] <= 250_000_000, 'stale active drag gate' self.record.update(prefix=page, lane=lane, requested_ns=requested, watchdog_deadline_ns=self.config['deadline_ns']) + if gated: + verify_held_gate(self.record) + for key, row in lanes(self.record['before']).items(): + assert all(row[field] == lanes(self.record['gate_status'])[key][field] + for field in ('epoch', 'desktop_generation', 'dispatches')), 'desktop changed before DPMS' self.mutated = True # Lost IPC reply may still mean power changed. assert _hypr(self.config['instance'], 'dispatch', _dpms_dispatch('off')) == 'ok', 'DPMS-off dispatcher unsupported' self.record['acknowledged_ns'] = time.monotonic_ns() @@ -280,7 +352,7 @@ def off(): return rows if all(row['dpmsStatus'] is False for row in rows) else None self.record['monitors_off'] = wait_for(off, timeout=2) self.record.update(after=production_status(self.config), observed_ns=time.monotonic_ns(), result='observed') - transition(self.record['gate_status'], self.record['after']) + transition(self.record['gate_status'], self.record['after'], pointer_cleanup(self.config), lane) guard() return lane @@ -318,6 +390,9 @@ def close(self): def verify_cancelled(boundary, record, action): assert record['result'] == 'observed' + policy = pointer_cleanup(record) + if policy == 'retained_inert' or 'min_motion_px' in record: + verify_held_gate(record) prefix, lane = record['prefix'], record['lane'] assert set(active_drags(prefix)) == {lane} assert prefix['events'][-1][1] <= record['requested_ns'] <= record['acknowledged_ns'] <= record['observed_ns'] @@ -334,11 +409,15 @@ def verify_cancelled(boundary, record, action): 'pointer_motion', 'pointer_enter') for row in tail), 'unexpected action after fault' assert not any(row[2] in ('pointer_motion', 'pointer_enter') and row[0] > cancelled[0][0] for row in tail), 'input continued after cancellation' + if policy == 'retained_inert': + assert not any(row[2] in ('pointer_enter', 'pointer_leave') for row in tail), 'retained pointer left or retargeted' releases = [row for row in tail if row[2] == 'pointer_button'] assert len(releases) == 1 and releases[0][6] == 0 and releases[0][0] > cancelled[0][0], 'missing own-seat release' + if policy == 'retained_inert': + assert releases[0][1] <= record['observed_ns'], 'release was not observed before inert status' isolation = analyze(stopped_prefix(boundary)) assert isolation['result'] == 'passed' and released_synthetic_input(stopped_prefix(boundary)), isolation - transition(record['gate_status'], record['after']) + transition(record['gate_status'], record['after'], policy, lane) return {'result': 'verified', 'outcome': fault_outcome(action), 'continuous_isolation': isolation} @@ -346,7 +425,14 @@ def verify_refusal(record): assert record['outcome'] == 'response' and record['replayed'] is False check_response(record['response'], {'kind': 'refused', 'reason': 'session_unavailable'}) assert not [row for row in trace_interval(record['trace_before'], record['trace_after']) if row[5] in (1, 2)], 'refused action emitted synthetic events' - before, after = lanes(record['before'], cleared=True), lanes(record['after'], cleared=True) + policy = pointer_cleanup(record) + before = lanes(record['before'], cleared=True, allow_passive=policy == 'retained_inert') + after = lanes(record['after'], cleared=True, allow_passive=policy == 'retained_inert') + if policy == 'retained_inert': + # A fresh CLAIM reserves capacity even when TARGET is refused. It is + # not the cancelled actor's lease and must disappear when this probe closes. + claim = verify_refusal_claim(record['before'], record['after'], record['response'], + interrupted_lane=record['lane']) for lane in before: assert all(before[lane][key] == after[lane][key] for key in ('epoch', 'desktop_generation', 'dispatches')), 'refused action dispatched or desktop changed' assert record['prepared_ns'] <= record['dispatch_ns'] <= record['observed_ns'] < record['deadline_ns'] @@ -354,7 +440,20 @@ def verify_refusal(record): assert all(row['dpmsStatus'] is False for row in record['monitors_before'] + record['monitors_after']) assert monitor_identity(record['monitors_before']) == monitor_identity(record['monitors_after']) assert analyze(stopped_prefix(record['trace_after']))['result'] == 'passed' - return {'result': 'verified', 'reason': 'session_unavailable', 'no_dispatch': 'verified'} + return {'result': 'verified', 'reason': 'session_unavailable', 'no_dispatch': 'verified', + **({'claim': claim} if policy == 'retained_inert' else {})} + + +def verify_refusal_close(record): + assert pointer_cleanup(record) == 'retained_inert' + assert type(record['exit_code']) is int, 'refusal runtime was not reaped' + assert record['observed_ns'] <= record['close_started_ns'] <= record['reaped_ns'] <= record['closed_ns'] < record['deadline_ns'] + verify_stable_inert(record['before'], record['after_close'], record['lane']) + verify_inert_interval(record['trace_after'], record['trace_after_close']) + assert all(row['dpmsStatus'] is False for row in record['monitors_after_close']), 'DPMS ended before probe close' + assert monitor_identity(record['monitors_after']) == monitor_identity(record['monitors_after_close']) + assert analyze(stopped_prefix(record['trace_after_close']))['result'] == 'passed' + return {'result': 'verified', 'reservation_released': True, 'no_dispatch': 'verified'} def prepare_refusal(prepared, spec, stage): @@ -390,9 +489,13 @@ def prepare_actions(clients, spec, stage, save): def refuse(client, spec, prepared, fault, trace, guard, save): record = {**prepared, 'outcome': 'unknown', 'replayed': False, 'runtime_pid': client.process.pid, + 'pointer_cleanup': pointer_cleanup(fault.config), 'deadline_ns': fault.config['deadline_ns'], 'before': production_status(fault.config), 'monitors_before': fault.unavailable(), 'trace_before': trace.collect()} try: + if pointer_cleanup(record) == 'retained_inert': + record['lane'] = fault.record['lane'] + verify_stable_inert(fault.record['after'], record['before'], record['lane']) guard() fault.live_deadline() record['dispatch_ns'] = time.monotonic_ns() @@ -403,6 +506,14 @@ def refuse(client, spec, prepared, fault, trace, guard, save): record.update(after=production_status(fault.config), monitors_after=fault.unavailable(), trace_after=trace.collect(), observed_ns=time.monotonic_ns()) record['verification'] = verify_refusal(record) + if pointer_cleanup(record) == 'retained_inert': + record['close_started_ns'] = time.monotonic_ns() + close_owned(client) + record.update(exit_code=client.process.poll(), reaped_ns=time.monotonic_ns()) + assert type(record['exit_code']) is int, 'refusal runtime was not reaped' + record.update(after_close=production_status(fault.config), monitors_after_close=fault.unavailable(), + trace_after_close=trace.collect(), closed_ns=time.monotonic_ns()) + record['close_verification'] = verify_refusal_close(record) return record finally: save('unavailable-action.json', record) @@ -507,20 +618,37 @@ def launch(name): boundary = trace.collect() save('fault-prefix.json', boundary) report['fault'] = verify_cancelled(boundary, fault.record, report['action']) + if pointer_cleanup(fault.config) == 'retained_inert': + close_owned(clients[0]) + assert clients[0].process.poll() is not None, 'interrupted runtime was not reaped' report['refusal'] = refuse(clients[1], spec, probe, fault, trace, guard, save) + if pointer_cleanup(fault.config) == 'retained_inert': + verify_inert_interval(boundary, report['refusal']['trace_before']) fault.unavailable() restoration = fault.restore() save('restoration.json', restoration) assert boundary['events'][-1][1] < restoration['started_ns'] assert report['refusal']['observed_ns'] <= restoration['started_ns'] assert restoration['observed_ns'] < fault.config['deadline_ns'] and not Path(fault.config['watchdog_path']).exists(), 'watchdog recovery is not proof' - transition(report['refusal']['after'], restoration['status']) + if pointer_cleanup(fault.config) == 'retained_inert': + assert report['refusal']['closed_ns'] <= restoration['started_ns'] + transition(report['refusal']['after_close'], restoration['status'], 'retained_inert', lane) + else: + transition(report['refusal']['after'], restoration['status']) for client in clients: close_owned(client) boundary = trace.collect() save('pre-recovery-prefix.json', boundary) report['teardown'] = verify_recovery_cleanup(report['refusal']['trace_after'], stopped_prefix(boundary)) preserve_interrupted_state(observer, spec, report['action'], restoration, guard, save) + if pointer_cleanup(fault.config) == 'retained_inert': + # Include runtime teardown and read-only app observation, before any recovery input. + before_recovery = production_status(fault.config) + verify_stable_inert(restoration['status'], before_recovery, lane) + boundary = trace.collect() + verify_inert_interval(report['refusal']['trace_after'], boundary) + save('pre-recovery-retained-status.json', before_recovery) + save('pre-recovery-prefix.json', boundary) clients.append(launch('recovery')) assert clients[-1].process.pid not in report['runtime_pids'], 'reused prior runtime' prefix = recover(clients[-1], observer, clients[0], spec, plan['recovery']['pointer_stage'], diff --git a/libs/cua-driver/hyprland-plugin/tests/production_session_fault_proof_test.py b/libs/cua-driver/hyprland-plugin/tests/production_session_fault_proof_test.py index a0c1ab1bef..fbdeae80cf 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production_session_fault_proof_test.py +++ b/libs/cua-driver/hyprland-plugin/tests/production_session_fault_proof_test.py @@ -496,5 +496,292 @@ def restored(config): os.close(reader) +def retained_status(generation=2, lane=1): + value = status(generation) + value['input']['lanes'][lane - 1]['pointer_focus'] = True + return value + + +def claimed_refusal(interrupted_lane=1, claimed_lane=0): + value = refusal_record() + value.update(pointer_cleanup='retained_inert', lane=interrupted_lane, + before=retained_status(2, interrupted_lane), after=retained_status(2, interrupted_lane), + after_close=retained_status(2, interrupted_lane), close_started_ns=15_000_000, + reaped_ns=15_500_000, exit_code=0, closed_ns=16_000_000, + monitors_after_close=[{**MONITOR, 'dpmsStatus': False}]) + value['response']['structuredContent']['lane'] = claimed_lane + value['after']['input']['lanes'][claimed_lane]['reserved'] = True + for key in ('trace_before', 'trace_after', 'trace_after_close'): + value[key] = trace(CANCEL[:-1]) + return value + + +def motion_gate(record, lane=1): + """A 13px surface-local movement, with the same held lane across status.""" + page = trace([(0, 'start', 0, 0), (1, 'agent_admitted', lane, 0), + (2, 'agent_drag_start', lane, 0), (2.5, 'pointer_enter', lane, 0), + (3, 'pointer_button', lane, 1), (4, 'pointer_motion', lane, 0)]) + for row in page['events']: + row[1] = int(row[1]) + if row[2] in ('pointer_enter', 'pointer_motion'): + row.extend([10 if row[2] == 'pointer_enter' else 23, 20]) + gate = status(1, held=True) + if lane == 2: + gate['input']['lanes'][0], gate['input']['lanes'][1] = gate['input']['lanes'][1], gate['input']['lanes'][0] + for index, row in enumerate(gate['input']['lanes']): + row['lane'], row['epoch'] = index, str(index + 1) * 32 + record.update(pointer_cleanup='retained_inert', min_motion_px=12, + prefix=page, gate_first=deepcopy(page), lane=lane, + status_started_ns=5_000_000, gate_status=gate, after=retained_status(2, lane)) + boundary = deepcopy(page) + boundary['events'] += [[7, 8_000_000, 'agent_cancel', 100, 100, lane, 0], + [8, 9_000_000, 'pointer_button', 100, 100, lane, 0]] + boundary['count'] = len(boundary['events']) + return boundary + + +class RetainedPointerTests(unittest.TestCase): + def test_fresh_claim_is_capacity_only_and_must_match_response_lane(self): + for interrupted in (1, 2): + for claimed in (0, 1): + value = claimed_refusal(interrupted, claimed) + self.assertEqual(proof.verify_refusal(value)['result'], 'verified') + for failure in ('foreign_reservation', 'old_reservation', 'lease_active', 'held_button', + 'held_keys', 'drag_active', 'keyboard_focus', 'dispatches', + 'desktop_generation', 'epoch', 'pointer_focus', 'response_lane', 'missing_lane'): + bad = deepcopy(value) + if failure == 'foreign_reservation': + bad['after']['input']['lanes'][1 - claimed]['reserved'] = True + elif failure == 'old_reservation': + bad['before']['input']['lanes'][claimed]['reserved'] = True + elif failure == 'response_lane': + bad['response']['structuredContent']['lane'] = 1 - claimed + elif failure == 'missing_lane': + del bad['response']['structuredContent']['lane'] + else: + row = bad['after']['input']['lanes'][interrupted - 1] + row[failure] = ('changed' if failure == 'epoch' else + False if failure == 'pointer_focus' else + row[failure] + 1 if type(row[failure]) is int else True) + with self.subTest(interrupted=interrupted, claimed=claimed, failure=failure), self.assertRaises(AssertionError): + proof.verify_refusal(bad) + + def test_probe_close_requires_all_capacity_released_while_still_off_and_quiet(self): + value = claimed_refusal() + self.assertEqual(proof.verify_refusal_close(value)['reservation_released'], True) + for failure in ('reserved', 'lease_active', 'held_keys', 'held_button', 'keyboard_focus', + 'drag_active', 'dispatches', 'desktop_generation', 'epoch', 'pointer_focus', + 'leave', 'motion', 'enter', 'history', 'deadline', 'clock', 'power', 'unreaped'): + bad = deepcopy(value) + if failure in ('leave', 'motion', 'enter'): + bad['trace_after_close'] = trace(CANCEL[:-1] + [(15, 'pointer_' + failure, 1, 0)]) + elif failure == 'history': + bad['trace_after_close']['events'][-1][3] += 1 + elif failure == 'deadline': + bad['closed_ns'] = bad['deadline_ns'] + elif failure == 'clock': + bad['close_started_ns'] = bad['observed_ns'] - 1 + elif failure == 'power': + bad['monitors_after_close'][0]['dpmsStatus'] = True + elif failure == 'unreaped': + bad['exit_code'] = None + else: + row = bad['after_close']['input']['lanes'][0] + row[failure] = ('changed' if failure == 'epoch' else + False if failure == 'pointer_focus' else + row[failure] + 1 if type(row[failure]) is int else True) + with self.subTest(failure=failure), self.assertRaises(AssertionError): + proof.verify_refusal_close(bad) + + def test_retained_refusal_reaps_probe_and_reads_back_before_returning_to_restore(self): + for failure in (None, 'reservation_survived', 'synthetic_close', 'runtime_live'): + value = claimed_refusal() + after_close = deepcopy(value['after_close']) + if failure == 'reservation_survived': + after_close['input']['lanes'][0]['reserved'] = True + trace_after_close = value['trace_after_close'] + if failure == 'synthetic_close': + trace_after_close = trace(CANCEL[:-1] + [(15, 'pointer_leave', 1, 0)]) + client = Mock(process=Mock(pid=100, poll=Mock(return_value=None if failure == 'runtime_live' else 0)), + tool=Mock(return_value=value['response'])) + fault = Mock(config={'pointer_cleanup': 'retained_inert', 'deadline_ns': 20_000_000}, + record={'lane': 1, 'after': retained_status()}, + unavailable=Mock(return_value=[{**MONITOR, 'dpmsStatus': False}])) + events = [] + states = iter([value['before'], value['after'], after_close]) + def status_read(*args): + events.append('status') + return next(states) + def close(runtime): + self.assertIs(runtime, client) + events.append('close') + save = Mock() + trace_client = Mock(collect=Mock(side_effect=[value['trace_before'], value['trace_after'], trace_after_close])) + with self.subTest(failure=failure), patch.object(proof, 'production_status', side_effect=status_read), \ + patch.object(proof, 'close_owned', side_effect=close), \ + patch.object(proof.time, 'monotonic_ns', side_effect=[13_000_000, 14_000_000, 15_000_000, 15_500_000, 16_000_000]): + args = (client, plan()['agents'][0], {'prepared_ns': 1_000_000, 'arguments': {}, 'session': 'probe'}, + fault, trace_client, Mock(), save) + if failure: + with self.assertRaises(AssertionError): + proof.refuse(*args) + else: + result = proof.refuse(*args) + self.assertEqual(result['close_verification']['result'], 'verified') + self.assertEqual(events, ['status', 'status', 'close', 'status']) + client.tool.assert_called_once() + self.assertEqual(events.count('close'), 1) + self.assertEqual(save.call_args.args[0], 'unavailable-action.json') + fault.restore.assert_not_called() + + def test_cleanup_and_motion_are_independent_opt_ins(self): + record = fault_record() + boundary = motion_gate(record) + del record['min_motion_px'] + proof.verify_cancelled(boundary, record, {'outcome': 'response', 'replayed': False, 'response': PARTIAL}) + record['min_motion_px'] = 12 + del record['pointer_cleanup'] + record['after'] = status(2) + proof.verify_cancelled(boundary, record, {'outcome': 'response', 'replayed': False, 'response': PARTIAL}) + + def test_optional_policies_and_finite_motion_do_not_relax_plan_scope(self): + value = plan() + value['fault'].update(pointer_cleanup='retained_inert', min_motion_px=12.5) + proof.validate_plan(value) + for change in ({'pointer_cleanup': 'anything'}, {'extra': True}, + *({'min_motion_px': v} for v in (None, True, 0, -1, float('inf'), float('nan'), '12'))): + bad = deepcopy(value) + bad['fault'].update(change) + with self.subTest(change=change), self.assertRaises(AssertionError): + proof.validate_plan(bad) + + def test_both_lanes_keep_inert_presence_only_when_explicitly_selected(self): + for lane in (1, 2): + record = fault_record() + boundary = motion_gate(record, lane) + result = proof.verify_cancelled(boundary, record, {'outcome': 'response', 'replayed': False, 'response': PARTIAL}) + self.assertEqual(result['result'], 'verified') + with self.assertRaises(AssertionError): + proof.transition(record['gate_status'], record['after']) + for key, value in (('held_button', 272), ('held_keys', 1), ('drag_active', True), + ('lease_active', True), ('keyboard_focus', True), ('reserved', True), + ('pointer_focus', False), ('dispatches', 1), ('epoch', 'changed'), + ('desktop_generation', 1)): + bad = deepcopy(record) + bad['after']['input']['lanes'][lane - 1][key] = value + with self.subTest(lane=lane, key=key), self.assertRaises(AssertionError): + proof.transition(bad['gate_status'], bad['after'], 'retained_inert', lane) + + def test_held_gate_rejects_insufficient_stale_retargeted_and_changed_lane_evidence(self): + record = fault_record() + motion_gate(record) + proof.verify_held_gate(record) + for failure in ('insufficient', 'coordinates', 'stale_status', 'stale_trace', + 'first_after_status', 'lane', 'leave', 'enter', 'released', 'unheld', 'history'): + bad = deepcopy(record) + if failure == 'insufficient': + bad['min_motion_px'] = 14 + elif failure == 'coordinates': + bad['prefix']['events'][-1] = bad['prefix']['events'][-1][:7] + elif failure == 'stale_status': + bad['status_started_ns'] = -300_000_000 + elif failure == 'stale_trace': + bad['requested_ns'] = 300_000_000 + elif failure == 'first_after_status': + bad['status_started_ns'] = 3_000_000 + elif failure == 'lane': + bad['lane'] = 2 + elif failure in ('leave', 'enter', 'released'): + kind = {'leave': 'pointer_leave', 'enter': 'pointer_enter', 'released': 'pointer_button'}[failure] + bad['prefix']['events'].append([7, 5_000_000, kind, 100, 100, 1, 0]) + bad['prefix']['count'] += 1 + elif failure == 'unheld': + bad['gate_status'] = status(1) + else: + bad['gate_first']['events'][-1][7] += 1 + with self.subTest(failure=failure), self.assertRaises(AssertionError): + proof.verify_held_gate(bad) + + def test_cancel_refuses_leave_reentry_motion_and_cross_lane_cleanup(self): + record = fault_record() + boundary = motion_gate(record) + action = {'outcome': 'response', 'replayed': False, 'response': PARTIAL} + for kind, lane in (('pointer_leave', 1), ('pointer_enter', 1), ('pointer_motion', 1), ('pointer_leave', 2)): + bad = deepcopy(boundary) + bad['events'].append([9, 10_000_000, kind, 100, 100, lane, 0]) + bad['count'] += 1 + with self.subTest(kind=kind, lane=lane), self.assertRaises(AssertionError): + proof.verify_cancelled(bad, record, action) + + def test_refusal_and_restoration_require_unchanged_presence_and_no_events(self): + value = claimed_refusal() + proof.verify_refusal(value) + proof.verify_refusal_close(value) + proof.transition(value['after_close'], retained_status(3), 'retained_inert', 1) + proof.verify_stable_inert(value['after_close'], retained_status(), 1) + for key, change in (('epoch', 'replaced'), ('desktop_generation', 3), ('reserved', True)): + bad = retained_status() + bad['input']['lanes'][0][key] = change + with self.subTest(key=key), self.assertRaises(AssertionError): + proof.verify_stable_inert(value['after_close'], bad, 1) + for key, field in (('before', 'reserved'), ('after', 'held_keys'), + ('after', 'dispatches'), ('after', 'pointer_focus')): + bad = deepcopy(value) + bad[key]['input']['lanes'][0][field] = False if field == 'pointer_focus' else (1 if field in ('held_keys', 'dispatches') else True) + with self.subTest(key=key, field=field), self.assertRaises(AssertionError): + proof.verify_refusal(bad) + before = trace(CANCEL[:-1]) + proof.verify_inert_interval(before, before) + for kind in ('pointer_leave', 'pointer_enter', 'pointer_motion', 'agent_admitted'): + after = trace(CANCEL[:-1] + [(15, kind, 1, 0)]) + with self.subTest(kind=kind), self.assertRaises(AssertionError): + proof.verify_inert_interval(before, after) + + def test_dpms_motion_gate_brackets_status_before_one_fault_dispatch(self): + for failure in (None, 'insufficient', 'changed_lane', 'generation', 'stale_status', 'timeout'): + fixture = object.__new__(proof.SessionFault) + fixture.config = {'instance': 'test', 'deadline_ns': 20_000_000, + 'pointer_cleanup': 'retained_inert', 'min_motion_px': 12} + fixture.record = {'before': status(1), 'pointer_cleanup': 'retained_inert', 'min_motion_px': 12} + fixture.mutated = False + fixture.check_targets = fixture.live_deadline = Mock() + record = fault_record() + motion_gate(record) + first, page = deepcopy(record['prefix']), deepcopy(record['prefix']) + gate = record['gate_status'] + if failure == 'insufficient': + first['events'][-1][7] = page['events'][-1][7] = 15 + if failure == 'changed_lane': + for row in page['events'][1:]: + row[5] = 2 + if failure == 'generation': + gate['input']['lanes'][0]['desktop_generation'] += 1 + calls = [] + def poll(*args, **kwargs): + calls.append('trace') + selected = first if calls.count('trace') == 1 else page + return selected, proof.active_drags(selected) + def read(*args): + calls.append('status') + return gate if calls.count('status') == 1 else retained_status() + with self.subTest(failure=failure), \ + patch.object(proof, 'control_lock', return_value=nullcontext()), \ + patch.object(proof, 'power', side_effect=[[{**MONITOR, 'dpmsStatus': True}], [{**MONITOR, 'dpmsStatus': False}]]), \ + patch.object(proof, 'poll_fault_active', side_effect=poll), \ + patch.object(proof, 'production_status', side_effect=read), \ + patch.object(proof.time, 'monotonic', side_effect=[0, 3.1] if failure == 'timeout' else None, return_value=0), \ + patch.object(proof.time, 'monotonic_ns', side_effect=[-300_000_000 if failure == 'stale_status' else 5_000_000, 6_000_000, 7_000_000, 12_000_000]), \ + patch.object(proof, 'wait_for', side_effect=lambda fn, timeout: fn()), \ + patch.object(proof, '_hypr', return_value='ok') as dispatch: + if failure: + with self.assertRaises(AssertionError): + fixture.inject(Mock(), trace([ACTIVE[0]]), Mock(done=Mock(return_value=False)), Mock()) + dispatch.assert_not_called() + else: + self.assertEqual(fixture.inject(Mock(), trace([ACTIVE[0]]), Mock(done=Mock(return_value=False)), Mock()), 1) + dispatch.assert_called_once() + self.assertEqual(calls[:3], ['trace', 'status', 'trace']) + + if __name__ == '__main__': unittest.main() From fa6a1ecea484765a307c6968b49dad8779ff65ae Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Thu, 10 Sep 2026 03:22:27 -0500 Subject: [PATCH 19/27] test(cua-driver): verify terminal connection cleanup on primary takeover --- .../tests/production_active_primary_proof.py | 59 +++++++++++-- .../production_active_primary_proof_test.py | 82 ++++++++++++++++++- 2 files changed, 131 insertions(+), 10 deletions(-) diff --git a/libs/cua-driver/hyprland-plugin/tests/production_active_primary_proof.py b/libs/cua-driver/hyprland-plugin/tests/production_active_primary_proof.py index 1581d2fc50..45a502cb25 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production_active_primary_proof.py +++ b/libs/cua-driver/hyprland-plugin/tests/production_active_primary_proof.py @@ -15,6 +15,11 @@ isolation across setup/transition is UNPROVEN. Only the distinct settled recovery action has a strict isolation claim. No replay of unknown/partial work. Portable tests establish preparation only, never native certification. + +Optional fault.min_motion_px requires continuously held surface-local motion +before takeover. After a partial response, Driver drops its input connection; +the immediate cancellation sample may precede that EOF, but the settled gate +requires every reservation released before recovery. Capacity is not authority. """ import argparse from production_app_smoke import add_provenance_arguments @@ -40,12 +45,13 @@ verify_fresh_observation, verify_recovery_cleanup, verify_recovery_trace) from production_lock_refusal_proof import click_once, verify_runtimes from production_mcp import DirectMCP, stop_process +from production_desktop_fault_proof import poll_fault_active, validate_min_motion import production_pointer_grounding as pointer_grounding from production_primary_conflict_proof import (ExactDesktop, clear_status, validate_plan as settled_plan) from production_realapp_proof import (app_process_identity, capacity_lane, check_response, provenance, trace_interval) -from production_session_fault_proof import lanes +from production_session_fault_proof import lanes, verify_held_gate from realapp_proof import cleanup_all, released_synthetic_input @@ -76,7 +82,10 @@ def verify_transition_end(boundary, stopped): def validate_plan(plan): assert plan['purpose'] == 'active_primary' and plan['case'] == 'active_drag' - assert plan['fault'] == {'kind': 'primary_hover'} + assert {'kind'} <= set(plan['fault']) <= {'kind', 'min_motion_px'} + assert plan['fault']['kind'] == 'primary_hover' + if 'min_motion_px' in plan['fault']: + validate_min_motion(plan['fault']['min_motion_px']) stages = ['scroll_down', 'scroll_up'] if plan.get('app_profile') == 'inkscape-only' else ['click_a1', 'click_b2'] assert plan['recovery'] == {'pointer_stages': stages} candidate = {k: v for k, v in plan.items() if k != 'fault'} @@ -117,14 +126,34 @@ def cancelled_status(before, after, lane): if key != lane - 1: assert old[key] == new[key], 'sibling lane changed' else: - # Unlike lock, primary_changed keeps this runtime's reservation. - assert new[key].get('reserved') is True, 'primary cancellation lost its reservation' + # Compositor cancellation keeps capacity until Driver consumes the + # partial response and drops its connection. Either side of that + # EOF is a valid immediate sample, never renewed input authority. + assert type(new[key].get('reserved')) is bool, 'invalid cancellation reservation' + + +def verify_terminal_reservation(before, after, lane): + cancelled_status(before, after, lane) + assert all(row['reserved'] is False for row in lanes(after).values()), 'terminal connection retained capacity' + return {'result': 'verified', 'unreserved': True, 'input_authority': False} + + +def await_terminal_reservation(desktop, before, lane): + def sample(): + current = desktop.status() + cancelled_status(before, current, lane) + return current if all(row['reserved'] is False for row in lanes(current).values()) else None + current = wait_for(sample, timeout=1) + return {'status': current, 'verification': verify_terminal_reservation(before, current, lane), + 'observed_ns': time.monotonic_ns()} def verify_cancelled(boundary, record, action, target): prefix, lane = record['prefix'], record['lane'] assert record['result'] == 'observed' and record['target'] == target assert set(active_drags(prefix)) == {lane} + if 'min_motion_px' in record: + verify_held_gate(record) assert all(row[5] in (0, lane) for row in prefix['events']), 'unowned synthetic input' assert all(row[2] in ('agent_approved', 'agent_admitted', 'agent_drag_start', 'pointer_enter', 'pointer_motion', 'pointer_button') @@ -254,6 +283,8 @@ def move(self, point, target, prepared_ns, *, pending=None): assert 0 <= requested - self.record['status_started_ns'] <= 250_000_000, 'stale held status' self.record.update(requested_ns=requested, point=point, target=target, prepared_ns=prepared_ns, deadline_ns=requested + HOVER_MS * 1_000_000) + if pending is not None and 'min_motion_px' in self.record: + verify_held_gate(self.record) self.sent = True # Lost ack still means the single command may have landed. self.child.stdin.write(f'MOVE {point[0]} {point[1]}\n'.encode('ascii')) self.child.stdin.flush() @@ -267,14 +298,23 @@ def move(self, point, target, prepared_ns, *, pending=None): def inject(self, trace, initial, pending, prepared): self.guard() - first, _ = poll_active(trace, initial, None, [pending], timeout=1) + motion = self.desktop.plan['fault'].get('min_motion_px') + if motion is None: + first, _ = poll_active(trace, initial, None, [pending], timeout=1) + else: + first, _ = poll_fault_active(trace, initial, pending, motion, timeout=1) started = time.monotonic_ns() gate = json.loads(_hypr(self.desktop.instance, '-j', 'cua:status')) - page, active = poll_active(trace, first, None, [pending], timeout=.25) + if motion is None: + page, active = poll_active(trace, first, None, [pending], timeout=.25) + else: + page, active = poll_fault_active(trace, first, pending, motion, timeout=.25) lane = next(iter(active)) held_status(gate, lane) assert analyze(stopped_prefix(page))['result'] == 'passed' - self.record.update(prefix=page, lane=lane, gate_status=gate, status_started_ns=started) + self.record.update(gate_first=first, prefix=page, lane=lane, gate_status=gate, status_started_ns=started) + if motion is not None: + self.record['min_motion_px'] = motion bounds = prepared['snapshot']['window_bounds'] point = [bounds['x'] + prepared['arguments']['from_x'], bounds['y'] + prepared['arguments']['from_y']] self.move(point, prepared['target'], prepared['prepared_ns'], pending=pending) @@ -346,7 +386,8 @@ def start_trace(): origin = provenance(args, plan) for name in (Path(__file__).name, 'production_active_primary_proof_test.py', 'primary_hover_fixture.c', 'primary_hover_fixture_test.py', - 'production_active_lock_proof.py', 'production_primary_conflict_proof.py'): + 'production_active_lock_proof.py', 'production_primary_conflict_proof.py', + 'production_desktop_fault_proof.py', 'production_session_fault_proof.py'): path = Path(__file__).with_name(name) origin['files'][name] = {'path': str(path.resolve()), 'sha256': hashlib.sha256(path.read_bytes()).hexdigest()} origin['hover_fixture'] = plan['hover_fixture'] @@ -374,6 +415,8 @@ def start_trace(): future = pool.submit(drag_once, actor, spec, prepared, action, save) fixture.inject(trace, initial, future, prepared) future.result(timeout=3) + report['connection_retirement'] = await_terminal_reservation(desktop, fixture.record['gate_status'], fixture.record['lane']) + save('connection-retirement.json', report['connection_retirement']) boundary = trace.collect() save('cancellation-boundary.json', boundary) save('cancellation-transition-analysis.json', transition_evidence(boundary)) diff --git a/libs/cua-driver/hyprland-plugin/tests/production_active_primary_proof_test.py b/libs/cua-driver/hyprland-plugin/tests/production_active_primary_proof_test.py index 9563ffbed6..954089a8cc 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production_active_primary_proof_test.py +++ b/libs/cua-driver/hyprland-plugin/tests/production_active_primary_proof_test.py @@ -170,11 +170,11 @@ def test_stale_hold_or_fixture_ack_fails(self): with self.subTest(section=section, key=key), self.assertRaises(AssertionError): self.verify(fault=fault) - def test_primary_cancel_preserves_generation_reservation_and_entire_sibling(self): + def test_primary_cancel_preserves_generation_and_entire_sibling(self): for section, key, value, lane in ( ('gate_status', 'held_button', 0, 0), ('gate_status', 'held_keys', 1, 0), ('after', 'held_button', 272, 0), ('after', 'held_keys', 1, 0), - ('after', 'reserved', False, 0), ('after', 'pointer_focus', True, 0), + ('after', 'reserved', None, 0), ('after', 'pointer_focus', True, 0), ('after', 'desktop_generation', 2, 0), ('after', 'epoch', 'changed', 0), ('after', 'dispatches', 1, 0), ('after', 'reserved', True, 1), ('after', 'desktop_generation', 2, 1)): @@ -183,6 +183,51 @@ def test_primary_cancel_preserves_generation_reservation_and_entire_sibling(self with self.subTest(section=section, key=key, lane=lane), self.assertRaises(AssertionError): self.verify(fault=fault) + def test_immediate_cancel_allows_eof_but_terminal_gate_requires_unreserved(self): + before = status(1, held=True) + for reserved in (True, False): + after = after_status() + after['input']['lanes'][0]['reserved'] = reserved + proof.cancelled_status(before, after, 1) + if reserved: + with self.assertRaisesRegex(AssertionError, 'retained capacity'): + proof.verify_terminal_reservation(before, after, 1) + else: + self.assertEqual(proof.verify_terminal_reservation(before, after, 1)['result'], 'verified') + for field, value in (('reserved', 0), ('reserved', 1), ('pointer_focus', True), + ('held_button', 272), ('lease_active', True)): + after = status(1) + after['input']['lanes'][0][field] = value + with self.subTest(field=field, value=value), self.assertRaises(AssertionError): + proof.verify_terminal_reservation(before, after, 1) + + def test_terminal_wait_is_bounded_and_checks_every_observed_state(self): + desktop = Mock() + desktop.status.side_effect = [after_status(), status(1)] + result = proof.await_terminal_reservation(desktop, status(1, held=True), 1) + self.assertTrue(result['verification']['unreserved']) + self.assertEqual(desktop.status.call_count, 2) + with patch.object(proof, 'wait_for', side_effect=RuntimeError('timeout')) as wait: + with self.assertRaisesRegex(RuntimeError, 'timeout'): + proof.await_terminal_reservation(Mock(), status(1, held=True), 1) + self.assertEqual(wait.call_args.kwargs['timeout'], 1) + broken = status(1) + broken['input']['lanes'][1]['reserved'] = True + with self.assertRaises(AssertionError): + proof.await_terminal_reservation(Mock(status=Mock(return_value=broken)), status(1, held=True), 1) + + def test_optional_motion_gate_rejects_invalid_and_unrecorded_displacement(self): + candidate = plan() + candidate['fault']['min_motion_px'] = 12 + proof.validate_plan(candidate) + for value in (True, 0, -1, float('inf'), float('nan'), '12'): + with self.subTest(value=value), self.assertRaises(AssertionError): + proof.validate_plan({**candidate, 'fault': {'kind': 'primary_hover', 'min_motion_px': value}}) + fault = record() + fault.update(min_motion_px=12, gate_first=deepcopy(fault['prefix'])) + with self.assertRaises(AssertionError): + self.verify(fault=fault) + def test_recovery_chooses_effective_stage_from_current_selection(self): before = {'snapshot_id': 'new-after-cancellation'} for b2, stage in ((True, 'click_a1'), (False, 'click_b2')): @@ -331,6 +376,39 @@ def test_injection_requires_both_current_trace_and_current_held_status(self): fixture.inject(Mock(), trace([ACTIVE[0]]), pending, prepared) fixture.move.assert_not_called() + def test_injection_uses_optional_motion_gate_on_both_sides_of_status(self): + fixture = self.fixture() + fixture.desktop.plan['fault']['min_motion_px'] = 12 + fixture.move = Mock() + prepared = {'snapshot': {'window_bounds': {'x': 10, 'y': 20}}, + 'arguments': {'from_x': 90, 'from_y': 80}, + 'target': plan()['agents'][0]['target'], 'prepared_ns': 1} + pending = Mock(done=Mock(return_value=False)) + page = trace(ACTIVE) + with patch.object(proof, 'poll_fault_active', return_value=(page, {1: 2_000_000})) as gate, \ + patch.object(proof, 'poll_active') as legacy, \ + patch.object(proof, '_hypr', side_effect=[json.dumps(status(1, held=True)), json.dumps(status(1))]), \ + patch.object(proof.time, 'monotonic_ns', side_effect=[5_000_000, 12_000_000]): + fixture.inject(Mock(), trace([ACTIVE[0]]), pending, prepared) + self.assertEqual(gate.call_count, 2) + self.assertEqual([call.args[3] for call in gate.call_args_list], [12, 12]) + self.assertEqual([call.kwargs['timeout'] for call in gate.call_args_list], [1, .25]) + legacy.assert_not_called() + self.assertEqual(fixture.record['min_motion_px'], 12) + self.assertEqual(fixture.record['gate_first'], page) + + def test_motion_gate_revalidated_before_sending_fixture_move(self): + fixture = self.fixture() + fixture.record['min_motion_px'] = 12 + pending = Mock(done=Mock(return_value=False)) + with patch.object(proof.time, 'monotonic_ns', return_value=6_000_000), \ + patch.object(proof, 'verify_held_gate', side_effect=AssertionError('motion insufficient')) as gate: + with self.assertRaisesRegex(AssertionError, 'motion insufficient'): + fixture.move([100, 100], plan()['agents'][0]['target'], 1, pending=pending) + gate.assert_called_once_with(fixture.record) + self.assertEqual(fixture.child.stdin.getvalue(), b'') + self.assertFalse(fixture.sent) + def test_close_checks_graceful_exit_and_finished_ack_without_input(self): fixture = self.fixture() fixture.sent = True From e25ecd1b35973f05635a96bb4a450facdb2fdaf0 Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Thu, 10 Sep 2026 03:37:56 -0500 Subject: [PATCH 20/27] test(cua-driver): model bounded primary hover motion after idle --- .../tests/primary_hover_fixture.c | 38 ++++++++----- .../tests/primary_hover_fixture_test.py | 13 ++++- .../tests/production_active_primary_proof.py | 39 ++++++++++++- .../production_active_primary_proof_test.py | 56 +++++++++++++++++++ 4 files changed, 127 insertions(+), 19 deletions(-) diff --git a/libs/cua-driver/hyprland-plugin/tests/primary_hover_fixture.c b/libs/cua-driver/hyprland-plugin/tests/primary_hover_fixture.c index 3eee8a1396..3df1dbbb7b 100644 --- a/libs/cua-driver/hyprland-plugin/tests/primary_hover_fixture.c +++ b/libs/cua-driver/hyprland-plugin/tests/primary_hover_fixture.c @@ -1,8 +1,11 @@ -/* TEST ONLY: one explicit primary hover, never a primary button/key. +/* TEST ONLY: one explicit primary hover gesture, never a primary button/key. * Build with the same generated virtual-pointer protocol as primary_grab.c. * Usage: primary_hover_fixture WIDTH HEIGHT LIFETIME_MS COMPOSITOR_PID - * READY creates no input. One MOVE X Y command sends motion+frame and waits - * for display.sync before reporting moved. The caller must independently + * READY creates no input. MOVE X Y sends one motion sample. Optional + * MOVE_FROM FROM_X FROM_Y X Y sends midpoint then destination, each followed + * by frame+sync and an observation. The caller attests the starting position. + * Two samples model follow-mouse movement after an idle interval without + * changing compositor policy or substituting a click. The caller must independently * attest the actual focused client; a sync is not a focus acknowledgement. * EOF, signals and a bounded deadline destroy only this fixture's pointer. */ @@ -107,18 +110,25 @@ int main(int argc, char **argv) { if (command[used++] == '\n') break; } } - unsigned x, y; char extra; + unsigned x, y, from_x = 0, from_y = 0; char extra; + int continuous = sscanf(command, "MOVE_FROM %u %u %u %u %c", &from_x, &from_y, &x, &y, &extra) == 4; if (!used || command[used - 1] != '\n' || stopped || now_ns() >= deadline || - sscanf(command, "MOVE %u %u %c", &x, &y, &extra) != 2 || x >= width || y >= height) return 1; - /* This is the only input request in the entire fixture. */ - zwlr_virtual_pointer_v1_motion_absolute(pointer, (uint32_t)(now_ns() / 1000000ULL), x, y, width, height); - zwlr_virtual_pointer_v1_frame(pointer); - start_sync(display); - deadline = now_ns() + 2000000000ULL; - while (!synced && !stopped && now_ns() < deadline) - if (pump(display) < 0) return 1; - if (!synced || stopped) return 1; - event("moved", x, y); + (!continuous && sscanf(command, "MOVE %u %u %c", &x, &y, &extra) != 2) || + x >= width || y >= height || from_x >= width || from_y >= height) return 1; + unsigned xs[2] = {(from_x + x) / 2, x}, ys[2] = {(from_y + y) / 2, y}; + if (continuous && ((xs[0] == from_x && ys[0] == from_y) || (xs[0] == x && ys[0] == y))) return 1; + /* Exactly one or two explicitly selected samples, never a replay loop. */ + for (unsigned step = continuous ? 0 : 1; step < 2; ++step) { + if (stopped) return 1; + zwlr_virtual_pointer_v1_motion_absolute(pointer, (uint32_t)(now_ns() / 1000000ULL), xs[step], ys[step], width, height); + zwlr_virtual_pointer_v1_frame(pointer); + start_sync(display); + deadline = now_ns() + 2000000000ULL; + while (!synced && !stopped && now_ns() < deadline) + if (pump(display) < 0) return 1; + if (!synced || stopped) return 1; + event(step == 0 ? "intermediate" : "moved", xs[step], ys[step]); + } int failed = 0, closed = 0; deadline = now_ns() + (uint64_t)lifetime * 1000000ULL; while (!stopped && now_ns() < deadline) { diff --git a/libs/cua-driver/hyprland-plugin/tests/primary_hover_fixture_test.py b/libs/cua-driver/hyprland-plugin/tests/primary_hover_fixture_test.py index 8bad7154ef..7370cd2fe3 100644 --- a/libs/cua-driver/hyprland-plugin/tests/primary_hover_fixture_test.py +++ b/libs/cua-driver/hyprland-plugin/tests/primary_hover_fixture_test.py @@ -22,17 +22,22 @@ def test_peer_identity_precedes_protocol_binding_or_input(self): self.assertIn('peer.pid != expected || peer.uid != getuid()', source) self.assertIn('SO_PEERCRED', source) - def test_ready_has_no_input_and_only_one_explicit_motion_is_possible(self): + def test_ready_has_no_input_and_only_a_bounded_explicit_gesture_is_possible(self): source = self.source self.assertNotIn('zwlr_virtual_pointer_v1_button(', source) self.assertEqual(source.count('zwlr_virtual_pointer_v1_motion_absolute('), 1) self.assertLess(source.index('event("ready",'), source.index('sscanf(command,')) self.assertLess(source.index('sscanf(command,'), source.index('zwlr_virtual_pointer_v1_motion_absolute(')) motion = source.index('zwlr_virtual_pointer_v1_motion_absolute(') - ack = source.index('event("moved",') + ack = source.index('event(step == 0 ? "intermediate" : "moved",') self.assertIn('start_sync(display);', source[motion:ack]) self.assertIn('if (!synced || stopped) return 1;', source[motion:ack]) self.assertIn('failed = !closed;', source[ack:]) + self.assertIn('step = continuous ? 0 : 1; step < 2; ++step', source) + self.assertIn('MOVE_FROM %u %u %u %u %c', source) + self.assertIn('from_x >= width || from_y >= height', source) + self.assertIn('xs[0] == from_x && ys[0] == from_y', source) + self.assertIn('xs[0] == x && ys[0] == y', source) def test_all_waits_are_bounded_and_partial_stdin_never_blocks_a_line_read(self): source = self.source @@ -64,6 +69,10 @@ def test_alias_replaced_inode_wrong_hash_or_unreviewed_source_fails(self): fixture.args = SimpleNamespace(source=root, hover_fixture=binary) fixture.expected = expected fixture.check_binary() + fixture.args.harness_source = root + fixture.args.source = root / 'different-product-checkout' + fixture.check_binary() # Test fixture provenance belongs to the harness. + fixture.args.source = root for field, value in (('path', str(root / 'other')), ('device', info.st_dev + 1), ('inode', info.st_ino + 1), ('uid', info.st_uid + 1), ('sha256', '0' * 64), ('source_sha256', '0' * 64)): diff --git a/libs/cua-driver/hyprland-plugin/tests/production_active_primary_proof.py b/libs/cua-driver/hyprland-plugin/tests/production_active_primary_proof.py index 45a502cb25..9867a1f9a2 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production_active_primary_proof.py +++ b/libs/cua-driver/hyprland-plugin/tests/production_active_primary_proof.py @@ -20,6 +20,9 @@ before takeover. After a partial response, Driver drops its input connection; the immediate cancellation sample may precede that EOF, but the settled gate requires every reservation released before recovery. Capacity is not authority. +Optional fault.motion_path=two_sample uses an explicitly recorded midpoint and +destination. This exercises native follow-mouse after idle without changing +its threshold, sending a click, or weakening the exact focused-client gate. """ import argparse from production_app_smoke import add_provenance_arguments @@ -82,10 +85,12 @@ def verify_transition_end(boundary, stopped): def validate_plan(plan): assert plan['purpose'] == 'active_primary' and plan['case'] == 'active_drag' - assert {'kind'} <= set(plan['fault']) <= {'kind', 'min_motion_px'} + assert {'kind'} <= set(plan['fault']) <= {'kind', 'min_motion_px', 'motion_path'} assert plan['fault']['kind'] == 'primary_hover' if 'min_motion_px' in plan['fault']: validate_min_motion(plan['fault']['min_motion_px']) + if 'motion_path' in plan['fault']: + assert plan['fault']['motion_path'] == 'two_sample' stages = ['scroll_down', 'scroll_up'] if plan.get('app_profile') == 'inkscape-only' else ['click_a1', 'click_b2'] assert plan['recovery'] == {'pointer_stages': stages} candidate = {k: v for k, v in plan.items() if k != 'fault'} @@ -169,6 +174,17 @@ def verify_cancelled(boundary, record, action, target): assert record['primary_after']['pid'] == target['pid'] and record['primary_after']['window_id'] == target['window_id'] assert record['primary_after']['cursor'] == dict(zip(('x', 'y'), record['point'])) tail = trace_interval(prefix, boundary) + if 'motion_path' in record: + intermediate = record['intermediate'] + assert record['motion_path'] == 'two_sample' + assert record['motion_from'] == list(prefix['events'][-1][3:5]), 'unobserved starting position' + assert intermediate['event'] == 'intermediate' + assert requested <= intermediate['observed_ns'] <= record['ack']['observed_ns'] + midpoint = [(a + b) // 2 for a, b in zip(record['motion_from'], record['point'])] + assert [intermediate['x'], intermediate['y']] == midpoint + assert midpoint not in (record['motion_from'], record['point']) + movements = [list(row[3:5]) for row in tail if row[5] == 0 and row[2] == 'cursor'] + assert movements == [midpoint, record['point']], 'unexpected primary motion path' assert any(row[5] == 0 and row[2] in ('pointer_focus', 'keyboard_focus') and requested <= row[1] <= observed for row in tail), 'missing primary focus transition' synthetic = [row for row in tail if row[5] in (1, 2)] @@ -216,7 +232,8 @@ def check_binary(self): assert stat.S_ISREG(info.st_mode) and os.access(path, os.X_OK) assert [info.st_dev, info.st_ino, info.st_uid] == [expected[k] for k in ('device', 'inode', 'uid')] assert hashlib.sha256(path.read_bytes()).hexdigest() == expected['sha256'] - source = self.args.source / 'libs/cua-driver/hyprland-plugin/tests/primary_hover_fixture.c' + source_root = getattr(self.args, 'harness_source', None) or self.args.source + source = source_root / 'libs/cua-driver/hyprland-plugin/tests/primary_hover_fixture.c' assert source.resolve(strict=True) == source assert hashlib.sha256(source.read_bytes()).hexdigest() == expected['source_sha256'] @@ -274,6 +291,16 @@ def move(self, point, target, prepared_ns, *, pending=None): # Target containment must remain true at the last identity check. window = next(w for w in self.desktop.guard() if w['pid'] == target['pid']) assert all(start < value < start + size for value, start, size in zip(point, window['at'], window['size'])) + if self.desktop.plan['fault'].get('motion_path') == 'two_sample': + current = json.loads(_hypr(self.desktop.instance, '-j', 'cursorpos')) + assert set(current) == {'x', 'y'} and all(type(v) is int for v in current.values()) + origin = [current['x'], current['y']] + assert 0 <= origin[0] < self.mode['width'] and 0 <= origin[1] < self.mode['height'] + midpoint = [(a + b) // 2 for a, b in zip(origin, point)] + assert midpoint not in (origin, point), 'two-sample gesture needs distinct points' + self.record.update(motion_path='two_sample', motion_from=origin) + if pending is not None: + assert origin == list(self.record['prefix']['events'][-1][3:5]), 'primary moved since held gate' requested = time.monotonic_ns() assert 0 <= requested - prepared_ns <= MAX_GROUNDING_AGE_NS, 'stale hover grounding' assert 0 <= requested - self.record['ready']['observed_ns'] < 2_500_000_000, 'ready window expired' @@ -286,8 +313,14 @@ def move(self, point, target, prepared_ns, *, pending=None): if pending is not None and 'min_motion_px' in self.record: verify_held_gate(self.record) self.sent = True # Lost ack still means the single command may have landed. - self.child.stdin.write(f'MOVE {point[0]} {point[1]}\n'.encode('ascii')) + command = f'MOVE {point[0]} {point[1]}\n' + if 'motion_path' in self.record: + command = f'MOVE_FROM {origin[0]} {origin[1]} {point[0]} {point[1]}\n' + self.child.stdin.write(command.encode('ascii')) self.child.stdin.flush() + if 'motion_path' in self.record: + self.record['intermediate'] = self.event('intermediate') + assert [self.record['intermediate']['x'], self.record['intermediate']['y']] == midpoint self.record['ack'] = self.event('moved') assert [self.record['ack']['x'], self.record['ack']['y']] == point self.record['primary_after'] = self.desktop.primary(target) diff --git a/libs/cua-driver/hyprland-plugin/tests/production_active_primary_proof_test.py b/libs/cua-driver/hyprland-plugin/tests/production_active_primary_proof_test.py index 954089a8cc..ba808bd803 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production_active_primary_proof_test.py +++ b/libs/cua-driver/hyprland-plugin/tests/production_active_primary_proof_test.py @@ -228,6 +228,43 @@ def test_optional_motion_gate_rejects_invalid_and_unrecorded_displacement(self): with self.assertRaises(AssertionError): self.verify(fault=fault) + def test_two_sample_path_requires_both_exact_observed_movements(self): + candidate = plan() + candidate['fault']['motion_path'] = 'two_sample' + proof.validate_plan(candidate) + with self.assertRaises(AssertionError): + proof.validate_plan({**candidate, 'fault': {'kind': 'primary_hover', 'motion_path': 'click'}}) + fault, page = record(), boundary() + origin = list(fault['prefix']['events'][-1][3:5]) + point = [int(origin[0]) + 100, int(origin[1])] + midpoint = [int(origin[0]) + 50, int(origin[1])] + fault.update(motion_path='two_sample', motion_from=origin, point=point, + intermediate={'event': 'intermediate', 'x': midpoint[0], 'y': midpoint[1], 'observed_ns': 6_500_000}) + fault['ack'].update(x=point[0], y=point[1]) + fault['primary_after']['cursor'] = dict(zip(('x', 'y'), point)) + n = len(fault['prefix']['events']) + page['events'][n:n] = [[0, 6_100_000, 'cursor', *midpoint, 0, 0], + [0, 6_600_000, 'cursor', *point, 0, 0]] + for index, row in enumerate(page['events'], 1): + row[0] = index + page['count'] = len(page['events']) + self.assertEqual(self.verify(page=page, fault=fault)['result'], 'verified') + for mutation in ('missing', 'wrong', 'extra', 'wrong_start', 'wrong_ack'): + bad_page, bad_fault = deepcopy(page), deepcopy(fault) + if mutation == 'missing': + bad_page['events'][n][2] = 'pointer_motion' + elif mutation == 'wrong': + bad_page['events'][n][3] += 1 + elif mutation == 'extra': + bad_page['events'].append([bad_page['count'] + 1, 12_000_000, 'cursor', *point, 0, 0]) + bad_page['count'] += 1 + elif mutation == 'wrong_start': + bad_fault['motion_from'][0] += 2 + else: + bad_fault['intermediate']['x'] += 1 + with self.subTest(mutation=mutation), self.assertRaises(AssertionError): + self.verify(page=bad_page, fault=bad_fault) + def test_recovery_chooses_effective_stage_from_current_selection(self): before = {'snapshot_id': 'new-after-cancellation'} for b2, stage in ((True, 'click_a1'), (False, 'click_b2')): @@ -409,6 +446,25 @@ def test_motion_gate_revalidated_before_sending_fixture_move(self): self.assertEqual(fixture.child.stdin.getvalue(), b'') self.assertFalse(fixture.sent) + def test_two_sample_command_records_midpoint_and_keeps_exact_focus_check(self): + fixture = self.fixture() + fixture.desktop.plan['fault']['motion_path'] = 'two_sample' + fixture.event.side_effect = [ + {'event': 'intermediate', 'x': 75, 'y': 100, 'observed_ns': 7_000_000}, + {'event': 'moved', 'x': 100, 'y': 100, 'observed_ns': 11_000_000}] + with patch.object(proof, '_hypr', return_value='{"x":50,"y":100}'), \ + patch.object(proof.time, 'monotonic_ns', side_effect=[6_000_000, 12_000_000]): + fixture.move([100, 100], plan()['agents'][0]['target'], 1) + self.assertEqual(fixture.child.stdin.getvalue(), b'MOVE_FROM 50 100 100 100\n') + self.assertEqual(fixture.record['motion_from'], [50, 100]) + fixture.desktop.primary.assert_called_once_with(plan()['agents'][0]['target']) + fixture = self.fixture() + fixture.desktop.plan['fault']['motion_path'] = 'two_sample' + with patch.object(proof, '_hypr', return_value='{"x":100,"y":100}'), self.assertRaises(AssertionError): + fixture.move([100, 100], plan()['agents'][0]['target'], 1) + self.assertFalse(fixture.sent) + self.assertEqual(fixture.child.stdin.getvalue(), b'') + def test_close_checks_graceful_exit_and_finished_ack_without_input(self): fixture = self.fixture() fixture.sent = True From 0fbab0e0fdc66dbd7fbf4756d32099977ecb7657 Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Thu, 10 Sep 2026 03:56:07 -0500 Subject: [PATCH 21/27] test(cua-driver): verify target-loss connection retirement --- .../tests/production_target_lifetime_proof.py | 25 ++++++++++- .../production_target_lifetime_proof_test.py | 41 +++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/libs/cua-driver/hyprland-plugin/tests/production_target_lifetime_proof.py b/libs/cua-driver/hyprland-plugin/tests/production_target_lifetime_proof.py index c03b91ad4a..276f686abd 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production_target_lifetime_proof.py +++ b/libs/cua-driver/hyprland-plugin/tests/production_target_lifetime_proof.py @@ -27,6 +27,10 @@ Same-client recovery, relaunch, PID/address reuse, active sibling cancellation, full desktop certification and physical hardware are explicitly unproven. No app launches, signer material, policy changes, production edits or replay. +Immediate cleanup accepts either boolean capacity-reservation state because +Driver drops its input connection after partial delivery. A separate bounded +gate requires all reservations released before recovery; input authority and +held input must be gone in every cleanup sample. """ import argparse from production_app_smoke import add_provenance_arguments @@ -272,11 +276,27 @@ def verify_cleared(before, after, lane): assert {k: v for k, v in old[key].items() if k not in resources} == { k: v for k, v in new[key].items() if k not in resources}, 'idle sibling lane changed' else: - assert new[key]['reserved'] is True, 'target loss unexpectedly lost runtime reservation' + assert type(new[key]['reserved']) is bool, 'invalid target-loss reservation state' assert all(type(new[key][k]) is int and new[key][k] < old[key][k] for k in resources), 'destroyed resources not pruned' +def verify_terminal_cleared(before, after, lane): + verify_cleared(before, after, lane) + assert all(row['reserved'] is False for row in lanes(after).values()), 'terminal connection retained capacity' + return {'result': 'verified', 'unreserved': True, 'input_authority': False} + + +def await_connection_retirement(fault, before, lane): + def sample(): + current = fault.status() + verify_cleared(before, current, lane) + return current if all(row['reserved'] is False for row in lanes(current).values()) else None + current = wait_for(sample, timeout=3) + return {'status': current, 'verification': verify_terminal_cleared(before, current, lane), + 'observed_ns': time.monotonic_ns()} + + def isolation(page, *, stopped=False): result = analyze(page if stopped else stopped_prefix(page)) assert result['result'] == 'passed', result @@ -467,6 +487,9 @@ def launch(name): (args.evidence / (name + '-saved-after' + Path(spec['owned']['document']['path']).suffix)).write_bytes(saved_document(spec)) report['saved_output'] = 'identity_and_bytes_unchanged; archived_before_and_after' close_owned(clients[0]) + report['connection_retirement'] = await_connection_retirement(fault, fault.record['gate_status'], lane) + save('connection-retirement.json', report['connection_retirement']) + guard() teardown = trace.collect() cleanup_trace(boundary, teardown) save('pre-recovery-prefix.json', teardown) diff --git a/libs/cua-driver/hyprland-plugin/tests/production_target_lifetime_proof_test.py b/libs/cua-driver/hyprland-plugin/tests/production_target_lifetime_proof_test.py index 8d2c391bba..766f6427b6 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production_target_lifetime_proof_test.py +++ b/libs/cua-driver/hyprland-plugin/tests/production_target_lifetime_proof_test.py @@ -137,6 +137,47 @@ def test_calc_requires_identity_and_exact_launch_argv(self): class OracleTests(unittest.TestCase): + def test_partial_connection_retirement_may_release_capacity_immediately(self): + for reserved in (True, False): + observed = record() + observed['after']['input']['lanes'][0]['reserved'] = reserved + self.assertEqual(proof.verify_fault(trace(DESTROYED), observed, action())['result'], 'verified') + for reserved in (None, 0, 1, 'false'): + observed = record() + observed['after']['input']['lanes'][0]['reserved'] = reserved + with self.subTest(reserved=reserved), self.assertRaises(AssertionError): + proof.verify_fault(trace(DESTROYED), observed, action()) + + def test_terminal_gate_requires_released_capacity_and_unchanged_sibling(self): + before, after = statuses() + with self.assertRaisesRegex(AssertionError, 'retained capacity'): + proof.verify_terminal_cleared(before, after, 1) + after['input']['lanes'][0]['reserved'] = False + self.assertTrue(proof.verify_terminal_cleared(before, after, 1)['unreserved']) + for field, value in (('held_button', 272), ('held_keys', 1), ('lease_active', True), + ('drag_active', True), ('pointer_focus', True), ('keyboard_focus', True)): + changed = deepcopy(after) + changed['input']['lanes'][0][field] = value + with self.subTest(field=field), self.assertRaises(AssertionError): + proof.verify_terminal_cleared(before, changed, 1) + after['input']['lanes'][1]['reserved'] = True + with self.assertRaises(AssertionError): + proof.verify_terminal_cleared(before, after, 1) + + def test_bounded_terminal_wait_does_not_accept_a_retained_reservation(self): + before, pending = statuses() + released = deepcopy(pending) + released['input']['lanes'][0]['reserved'] = False + fault = SimpleNamespace(status=Mock(side_effect=[pending, released])) + def wait(sample, timeout): + self.assertEqual(timeout, 3) + self.assertIsNone(sample()) + return sample() + with patch.object(proof, 'wait_for', side_effect=wait): + result = proof.await_connection_retirement(fault, before, 1) + self.assertTrue(result['verification']['unreserved']) + self.assertEqual(fault.status.call_count, 2) + def test_destroyed_surface_requires_no_invented_release(self): result = proof.verify_fault(trace(DESTROYED), record(), action()) self.assertEqual(result['wire_release_events'], 0) From 1a9c4985b3e1575ad3bd95182bede74c76789cff Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Thu, 10 Sep 2026 04:03:27 -0500 Subject: [PATCH 22/27] build(cua-driver): bind repaired plugin source in profile kits --- .../packaging/release/lifecycle.py | 5 +- .../packaging/release/profile-contract.md | 32 ++++- .../packaging/release/profile_bundle.py | 11 +- .../release/profile_download_recipe.py | 19 +-- .../packaging/release/profile_verify.py | 30 +++-- .../release/test_profile_download_recipe.py | 47 ++++++-- .../packaging/release/test_profile_release.py | 109 +++++++++++++++--- 7 files changed, 201 insertions(+), 52 deletions(-) diff --git a/libs/cua-driver/hyprland-plugin/packaging/release/lifecycle.py b/libs/cua-driver/hyprland-plugin/packaging/release/lifecycle.py index 8acc93c400..322d7be0aa 100644 --- a/libs/cua-driver/hyprland-plugin/packaging/release/lifecycle.py +++ b/libs/cua-driver/hyprland-plugin/packaging/release/lifecycle.py @@ -81,7 +81,8 @@ def verify_profile_kit(kit, revision, driver_version, kit_sha256): exec(compile(verifier_path.read_bytes(), str(verifier_path), "exec"), verifier.__dict__) profile, provenance = verifier.verify_kit(kit, kit_sha256, complete=True) require(revision == profile["source"]["revision"] and driver_version == profile["source"]["driver_version"], "kit source revision/version mismatch") - expected = set(verifier.TOOLING) | {"PROFILE.json", "KIT-PROVENANCE.json", "SOURCE-PROVENANCE.json", "PKGBUILD", verifier.STEM + ".tar.gz"} + stem = verifier.source_stem(profile) + expected = set(verifier.TOOLING) | {"PROFILE.json", "KIT-PROVENANCE.json", "SOURCE-PROVENANCE.json", "PKGBUILD", stem + ".tar.gz"} checksums = {} for line in (kit / "SHA256SUMS").read_text().splitlines(): checksum, name = line.split(" ") @@ -92,7 +93,7 @@ def verify_profile_kit(kit, revision, driver_version, kit_sha256): path = kit / name require(path.is_file() and not path.is_symlink() and digest(path.read_bytes()) == checksum, f"kit checksum mismatch: {name}") require(digest(Path(__file__).read_bytes()) == provenance["tooling_files"]["lifecycle.py"], "runner differs from reviewed kit") - manifest = verifier.verify_archive(kit / (verifier.STEM + ".tar.gz"), profile) + manifest = verifier.verify_archive(kit / (stem + ".tar.gz"), profile) require(verifier.source_manifest((kit / "SOURCE-PROVENANCE.json").read_bytes(), profile) == manifest, "kit historical manifest mismatch") return manifest, checksums, profile, provenance diff --git a/libs/cua-driver/hyprland-plugin/packaging/release/profile-contract.md b/libs/cua-driver/hyprland-plugin/packaging/release/profile-contract.md index a5a9b1352e..59216b21fb 100644 --- a/libs/cua-driver/hyprland-plugin/packaging/release/profile-contract.md +++ b/libs/cua-driver/hyprland-plugin/packaging/release/profile-contract.md @@ -20,9 +20,28 @@ qualification evidence. A profile is reviewed data, not an instruction to accept whatever environment the builder discovers. Preserve exact compositor, headers, compiler, shared-runtime, source-integrity, and production-build checks. +Schema 1 remains restricted to the original Driver 0.24.0 source revision +`4b3396d9fe4bd3cf723b0eb8db83c18a8764b520`. Schema 2 uses the same profile fields +but allows an explicitly reviewed full source revision and stable Driver version, +with separately selected archive and manifest SHA-256 digests. Both schemas +preserve the selected archive and manifest bytes. The archive stem and package +version follow that validated source identity throughout generation, download +recipe export, and lifecycle checks. + +The reviewer selects the source digests as trust roots. An archive inventory, +embedded manifest, or environment measurement cannot authorize its own source. +The source manifest must match the full declared revision and Driver version, +including the corresponding component release-tag field, and retain the original +manifest shape, production build flags, plugin version, architecture, and +historical build-environment fields. Schema 2 does not relax the profile's exact +compositor, compiler, header, runtime, mandatory-test, or activation checks. + Changing the native input implementation or Driver's application admission is -outside a packaging-only rebuild. Such changes need a reviewed source revision -and their affected native evidence. +outside a packaging-only rebuild. Such changes need a separately reviewed source +revision and their affected native evidence. Schema 2 supports packaging that +candidate; neither generation nor a manifest's release-tag field proves that +the candidate is released. A local commit with a published version number must +never be relabeled or published as the existing release when its source differs. ## Initial qualification @@ -62,7 +81,14 @@ dependency. Retain a matching rollback set. Cua owns source tooling and native input qualification. The distribution names its package-maintenance and signing owner before rollout. Relevant dependency changes trigger a candidate build and requalification, not an automatic -compatibility claim. Manual publication must use the certified package bytes; +compatibility claim. Before publication, resolve the exact component tag +`cua-driver-rs-v` and verify that it identifies the profile's full +source revision. A mismatched candidate needs the proper component release and +a profile bound to that exact source. Reuse qualification only for unchanged +source and package bytes; changed bytes require the affected qualification anew. +Download recipe generation only prepares a reviewable future URL; it does not +verify a remote tag or authorize publication. +Manual publication must use the certified package bytes; unattended distribution requires an enforced artifact-to-evidence gate. The downstream implementation remains diff --git a/libs/cua-driver/hyprland-plugin/packaging/release/profile_bundle.py b/libs/cua-driver/hyprland-plugin/packaging/release/profile_bundle.py index 88c6bb730c..7c2f503b41 100644 --- a/libs/cua-driver/hyprland-plugin/packaging/release/profile_bundle.py +++ b/libs/cua-driver/hyprland-plugin/packaging/release/profile_bundle.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Wrap unchanged Driver 0.24.0 source in a separately reviewed native-profile kit.""" +"""Wrap explicitly reviewed source bytes in a native-profile candidate kit.""" import argparse import gzip @@ -45,6 +45,7 @@ def generate(repo, tooling_revision, profile_path, source_archive, output): verify.require(profile_path.is_file() and not profile_path.is_symlink(), "profile must be an explicit regular file") profile_data = profile_path.read_bytes() profile = verify.validate_profile(verify.read_json(profile_data)) + stem = verify.source_stem(profile) manifest = verify.verify_archive(source_archive, profile) provenance = {"schema": 1, "tooling_revision": tooling_revision, "profile_sha256": verify.sha256(profile_data), "source": profile["source"], "cmake_options": verify.OPTIONS, "native_certified": False, @@ -54,14 +55,14 @@ def generate(repo, tooling_revision, profile_path, source_archive, output): payload["SOURCE-PROVENANCE.json"] = verify.json_bytes(manifest) # Preserve original manifest bytes too, even if its JSON formatting differs. with tarfile.open(source_archive, "r:gz") as archive: - payload["SOURCE-PROVENANCE.json"] = archive.extractfile(verify.STEM + "/SOURCE-PROVENANCE.json").read() - payload[verify.STEM + ".tar.gz"] = source_archive.read_bytes() - verify.require(verify.sha256(payload[verify.STEM + ".tar.gz"]) == profile["source"]["archive_sha256"], "source archive changed during generation") + payload["SOURCE-PROVENANCE.json"] = archive.extractfile(stem + "/SOURCE-PROVENANCE.json").read() + payload[stem + ".tar.gz"] = source_archive.read_bytes() + verify.require(verify.sha256(payload[stem + ".tar.gz"]) == profile["source"]["archive_sha256"], "source archive changed during generation") verify.source_manifest(payload["SOURCE-PROVENANCE.json"], profile) payload["PKGBUILD"] = verify.render_recipe(payload["PROFILE-PKGBUILD.in"].decode(), profile, provenance) payload["SHA256SUMS"] = "".join(f"{verify.sha256(data)} {name}\n" for name, data in sorted(payload.items())).encode() # The archive identity binds profile bytes and tooling commit, not just labels. - name = (f"{verify.STEM}-profile-{profile['profile_id']}-kit-{profile['kit_version']}" + name = (f"{stem}-profile-{profile['profile_id']}-kit-{profile['kit_version']}" f"-{provenance['profile_sha256']}-{tooling_revision}.tar.gz") archive_data = deterministic_archive(payload) output.mkdir(parents=True, exist_ok=False) diff --git a/libs/cua-driver/hyprland-plugin/packaging/release/profile_download_recipe.py b/libs/cua-driver/hyprland-plugin/packaging/release/profile_download_recipe.py index 0b47cc9097..93da342fec 100644 --- a/libs/cua-driver/hyprland-plugin/packaging/release/profile_download_recipe.py +++ b/libs/cua-driver/hyprland-plugin/packaging/release/profile_download_recipe.py @@ -17,7 +17,7 @@ HERE = Path(__file__).resolve().parent INVENTORY = set(verify.TOOLING) | { "PROFILE.json", "KIT-PROVENANCE.json", "SOURCE-PROVENANCE.json", - "PKGBUILD", "SHA256SUMS", verify.STEM + ".tar.gz", + "PKGBUILD", "SHA256SUMS", } @@ -28,10 +28,14 @@ def archive_payload(data): for member in archive: verify.require(member.isfile() and not member.issparse() and not member.pax_headers, "outer kit contains a nonregular or extended member") - verify.require(member.name in INVENTORY, "unsafe or unexpected outer kit path") + verify.require(member.name in INVENTORY or re.fullmatch( + r"cua-hyprland-plugin-[0-9]+\.[0-9]+\.[0-9]+-[0-9a-f]{40}\.tar\.gz", member.name), + "unsafe or unexpected outer kit path") verify.require(member.name not in payload, "duplicate outer kit member") payload[member.name] = archive.extractfile(member).read() - verify.require(set(payload) == INVENTORY, "outer kit inventory mismatch") + verify.require("PROFILE.json" in payload, "outer kit inventory mismatch") + profile = verify.validate_profile(verify.read_json(payload["PROFILE.json"])) + verify.require(set(payload) == INVENTORY | {verify.source_stem(profile) + ".tar.gz"}, "outer kit inventory mismatch") expected_sums = "".join(f"{verify.sha256(data)} {name}\n" for name, data in sorted(payload.items()) if name != "SHA256SUMS").encode() verify.require(payload["SHA256SUMS"] == expected_sums, "outer kit SHA256SUMS mismatch") @@ -55,11 +59,12 @@ def reviewed_kit(archive, expected_sha): verify.require(payload["KIT-PROVENANCE.json"] == verify.json_bytes(provenance), "kit provenance must match the recipe's canonical checksum") verify.source_manifest(payload["SOURCE-PROVENANCE.json"], profile) - verify.verify_archive(kit / (verify.STEM + ".tar.gz"), profile) - with tarfile.open(fileobj=io.BytesIO(payload[verify.STEM + ".tar.gz"]), mode="r:gz") as source: + stem = verify.source_stem(profile) + verify.verify_archive(kit / (stem + ".tar.gz"), profile) + with tarfile.open(fileobj=io.BytesIO(payload[stem + ".tar.gz"]), mode="r:gz") as source: verify.require(all(member.isfile() and not member.issparse() and not member.pax_headers for member in source), "source archive contains a nonregular or extended member") - expected_name = (f"{verify.STEM}-profile-{profile['profile_id']}-kit-{profile['kit_version']}" + expected_name = (f"{stem}-profile-{profile['profile_id']}-kit-{profile['kit_version']}" f"-{provenance['profile_sha256']}-{provenance['tooling_revision']}.tar.gz") verify.require(archive.name == expected_name, "outer archive filename does not match kit identity") return payload, profile, provenance @@ -161,7 +166,7 @@ def adapt_recipe(payload, profile, provenance, archive_name, expected_sha, downl recipe = replace_once(recipe, "prepare() {\n _verify\n}", "prepare() {\n _verify_download extract || return 1\n _verify\n}") runtime = DOWNLOAD_CHECK.replace("@MEMBER_HASHES@", repr({name: verify.sha256(data) for name, data in sorted(payload.items())})) - runtime = runtime.replace("@STEM@", verify.STEM) + runtime = runtime.replace("@STEM@", verify.source_stem(profile)) recipe = replace_once(recipe, "\n_verify() {\n", runtime + "\n_verify() {\n") verify.require("$startdir" not in recipe, "unadapted startdir reference") # build/check/package retain every original instruction, with only kit paths moved. diff --git a/libs/cua-driver/hyprland-plugin/packaging/release/profile_verify.py b/libs/cua-driver/hyprland-plugin/packaging/release/profile_verify.py index e90ea69623..8311bf94fd 100644 --- a/libs/cua-driver/hyprland-plugin/packaging/release/profile_verify.py +++ b/libs/cua-driver/hyprland-plugin/packaging/release/profile_verify.py @@ -70,14 +70,18 @@ def hash_value(value): def validate_profile(profile): keys(profile, "schema profile_id kit_version package_release source architecture hyprland compiler runtime", "profile") - require(type(profile["schema"]) is int and profile["schema"] == 1, "unsupported profile schema") + require(type(profile["schema"]) is int and profile["schema"] in (1, 2), "unsupported profile schema") require(len(profile["profile_id"]) <= 32 and re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", profile["profile_id"]), "invalid profile ID") require(len(profile["kit_version"]) <= 20 and re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", profile["kit_version"]), "invalid kit version") require(type(profile["package_release"]) is int and profile["package_release"] >= 2, "profile package release must be >=2") require(profile["architecture"] == "x86_64", "only x86_64 is supported") source = profile["source"] keys(source, "revision driver_version archive_sha256 manifest_sha256", "source") - require(source["revision"] == SOURCE_REVISION and source["driver_version"] == DRIVER_VERSION, "requires the original Driver 0.24.0 source") + require(isinstance(source["revision"], str) and re.fullmatch(r"[0-9a-f]{40}", source["revision"]), "requires a full source commit SHA") + require(isinstance(source["driver_version"], str) and len(source["driver_version"]) <= 20 and + re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", source["driver_version"]), "requires a stable Driver version") + if profile["schema"] == 1: + require(source["revision"] == SOURCE_REVISION and source["driver_version"] == DRIVER_VERSION, "requires the original Driver 0.24.0 source") hash_value(source["archive_sha256"]) hash_value(source["manifest_sha256"]) hyprland = profile["hyprland"] @@ -104,17 +108,23 @@ def validate_profile(profile): return profile +def source_stem(profile): + source = validate_profile(profile)["source"] + return f"cua-hyprland-plugin-{source['driver_version']}-{source['revision']}" + + def source_manifest(data, profile): + source = validate_profile(profile)["source"] require(sha256(data) == profile["source"]["manifest_sha256"], "historical manifest checksum mismatch") manifest = read_json(data) - expected = {"schema": 1, "source_revision": SOURCE_REVISION, "driver_version": DRIVER_VERSION, - "release_tag": "cua-driver-rs-v0.24.0", "plugin_version": "0.1.0", + expected = {"schema": 1, "source_revision": source["revision"], "driver_version": source["driver_version"], + "release_tag": "cua-driver-rs-v" + source["driver_version"], "plugin_version": "0.1.0", "architecture": "x86_64", "native_certified": False, "cmake_options": OPTIONS, "hyprland_version": "0.56.2", "hyprland_package": "0.56.2-1", "compiler_version": "16.1.1 20260728", "compiler_comment": "GCC: (GNU) 16.1.1 20260728"} require(set(manifest) == set(expected) | {"files"}, "invalid historical manifest fields") for key, value in expected.items(): - require(manifest[key] == value, f"historical source provenance mismatch: {key}") + require(type(manifest[key]) is type(value) and manifest[key] == value, f"historical source provenance mismatch: {key}") require(isinstance(manifest["files"], dict) and {"CMakeLists.txt", "LICENSE.md", "verify.py"} <= set(manifest["files"]), "invalid source inventory") for name, checksum in manifest["files"].items(): path = PurePosixPath(name) @@ -124,13 +134,14 @@ def source_manifest(data, profile): def verify_archive(archive, profile): + stem = source_stem(profile) require(archive.is_file() and not archive.is_symlink(), "source archive must be a regular file") require(digest(archive) == profile["source"]["archive_sha256"], "source archive checksum mismatch") payload = {} with tarfile.open(archive, "r:gz") as contents: for member in contents: - require(member.isfile() and member.name.startswith(STEM + "/"), "invalid source archive member") - name = member.name[len(STEM) + 1:] + require(member.isfile() and member.name.startswith(stem + "/"), "invalid source archive member") + name = member.name[len(stem) + 1:] path = PurePosixPath(name) require(name and path.as_posix() == name and not path.is_absolute() and ".." not in path.parts and "\\" not in name, "unsafe source archive path") require(name not in payload, "duplicate source archive member") @@ -185,8 +196,9 @@ def verify_kit(kit, expected_sha, *, complete=False): def render_recipe(template, profile, provenance): - replacements = {"DRIVER_VERSION": DRIVER_VERSION, "PKGREL": str(profile["package_release"]), - "PROFILE_ID": profile["profile_id"], "STEM": STEM, + stem = source_stem(profile) + replacements = {"DRIVER_VERSION": profile["source"]["driver_version"], "PKGREL": str(profile["package_release"]), + "PROFILE_ID": profile["profile_id"], "STEM": stem, "HYPRLAND_PACKAGE": profile["hyprland"]["package_version"], "RUNTIME_DEPENDS": " ".join(f"'{name}={version}'" for name, version in sorted(profile["runtime"]["packages"].items())), "ARCHIVE_SHA256": profile["source"]["archive_sha256"], diff --git a/libs/cua-driver/hyprland-plugin/packaging/release/test_profile_download_recipe.py b/libs/cua-driver/hyprland-plugin/packaging/release/test_profile_download_recipe.py index 247d3f580f..9b929bcdab 100644 --- a/libs/cua-driver/hyprland-plugin/packaging/release/test_profile_download_recipe.py +++ b/libs/cua-driver/hyprland-plugin/packaging/release/test_profile_download_recipe.py @@ -13,8 +13,10 @@ class DownloadRecipeTest(unittest.TestCase): + fixture_class = fixtures.ProfileTest + def setUp(self): - self.fixture = fixtures.ProfileTest() + self.fixture = self.fixture_class() self.fixture.setUp() self.addCleanup(self.fixture.doCleanups) self.root = self.fixture.root @@ -50,7 +52,7 @@ def extract(self): self.generate() result = self.shell("_verify_download extract") self.assertEqual(result.returncode, 0, result.stderr) - return self.srcdir / "cua-profile-kit", self.srcdir / verify.STEM + return self.srcdir / "cua-profile-kit", self.srcdir / self.fixture.stem def test_deterministic_export_and_unchanged_kit(self): original_archive = self.archive.read_bytes() @@ -136,7 +138,7 @@ def test_outer_inventory_paths_duplicates_and_links_refused(self): def test_internal_hashes_provenance_and_unreviewed_code_refused(self): for name in ("SHA256SUMS", "PROFILE.json", "KIT-PROVENANCE.json", "SOURCE-PROVENANCE.json", - "profile_verify.py", "PKGBUILD", verify.STEM + ".tar.gz"): + "profile_verify.py", "PKGBUILD", self.fixture.stem + ".tar.gz"): candidate = dict(self.payload) candidate[name] += b"\n# tampered\n" self.rewrite(candidate, sums=name != "SHA256SUMS") @@ -149,12 +151,12 @@ def test_internal_hashes_provenance_and_unreviewed_code_refused(self): self.generate() def test_source_inventory_validated_even_with_consistent_outer_hashes(self): - files = {verify.STEM + "/" + name: data for name, data in self.fixture.files.items()} - files[verify.STEM + "/extra"] = b"unexpected source" + files = {self.fixture.stem + "/" + name: data for name, data in self.fixture.files.items()} + files[self.fixture.stem + "/extra"] = b"unexpected source" candidate = dict(self.payload) - candidate[verify.STEM + ".tar.gz"] = bundle.deterministic_archive(files) + candidate[self.fixture.stem + ".tar.gz"] = bundle.deterministic_archive(files) profile = verify.read_json(candidate["PROFILE.json"]) - profile["source"]["archive_sha256"] = verify.sha256(candidate[verify.STEM + ".tar.gz"]) + profile["source"]["archive_sha256"] = verify.sha256(candidate[self.fixture.stem + ".tar.gz"]) candidate["PROFILE.json"] = verify.json_bytes(profile) provenance = verify.read_json(candidate["KIT-PROVENANCE.json"]) provenance["source"] = profile["source"] @@ -169,12 +171,12 @@ def test_source_extended_metadata_refused_before_export(self): raw = io.BytesIO() with tarfile.open(fileobj=raw, mode="w:gz", format=tarfile.PAX_FORMAT) as archive: for name, data in self.fixture.files.items(): - member = tarfile.TarInfo(verify.STEM + "/" + name) + member = tarfile.TarInfo(self.fixture.stem + "/" + name) member.size = len(data) member.pax_headers = {"comment": "unreviewed metadata"} archive.addfile(member, io.BytesIO(data)) candidate = dict(self.payload) - candidate[verify.STEM + ".tar.gz"] = raw.getvalue() + candidate[self.fixture.stem + ".tar.gz"] = raw.getvalue() profile = verify.read_json(candidate["PROFILE.json"]) profile["source"]["archive_sha256"] = verify.sha256(raw.getvalue()) candidate["PROFILE.json"] = verify.json_bytes(profile) @@ -233,7 +235,7 @@ def test_wrapper_owned_check_ignores_pythonpath_import_shadowing(self): def test_prepare_refuses_existing_symlink_destinations_before_writes(self): self.generate() - for name in ("cua-profile-kit", verify.STEM): + for name in ("cua-profile-kit", self.fixture.stem): link = self.srcdir / name link.symlink_to(self.startdir, target_is_directory=True) result = self.shell("prepare") @@ -245,7 +247,7 @@ def test_prepare_refuses_existing_symlink_destinations_before_writes(self): def test_every_phase_refuses_tampered_outer_kit_and_source(self): kit, source = self.extract() targets = [self.archive, kit / "PROFILE.json", kit / "profile_verify.py", kit / "KIT-PROVENANCE.json", - kit / (verify.STEM + ".tar.gz"), kit / "PKGBUILD", source / "src/plugin.cpp"] + kit / (self.fixture.stem + ".tar.gz"), kit / "PKGBUILD", source / "src/plugin.cpp"] for target in targets: original = target.read_bytes() target.write_bytes(b"raise SystemExit('UNTRUSTED_CODE_EXECUTED')\n") @@ -293,5 +295,28 @@ def test_build_check_package_are_preserved_and_ctest_failure_blocks_package(self self.assertNotIn("UNEXPECTED_INSTALL", result.stderr) +class Schema2DownloadRecipeTest(DownloadRecipeTest): + fixture_class = fixtures.Schema2ProfileTest + + def test_outer_source_name_must_match_validated_profile(self): + for name in (verify.STEM + ".tar.gz", "../" + self.fixture.stem + ".tar.gz", + self.fixture.stem + ".tar.gz/extra", self.fixture.stem.replace("0.26.0", "0.25.0") + ".tar.gz"): + candidate = dict(self.payload) + candidate[name] = candidate.pop(self.fixture.stem + ".tar.gz") + self.rewrite(candidate) + with self.subTest(name=name), self.assertRaisesRegex(ValueError, "inventory|path"): + self.generate() + self.assertFalse(self.output.exists()) + + def test_wrapper_binds_candidate_source_and_version(self): + kit, source = self.extract() + recipe = self.output.read_text() + self.assertIn("pkgver=" + self.fixture.driver_version, recipe) + self.assertIn("stem = '" + self.fixture.stem + "'", recipe) + self.assertNotIn(verify.STEM, recipe) + self.assertEqual(source.name, self.fixture.stem) + self.assertEqual((kit / (self.fixture.stem + ".tar.gz")).read_bytes(), self.fixture.archive.read_bytes()) + + if __name__ == "__main__": unittest.main() diff --git a/libs/cua-driver/hyprland-plugin/packaging/release/test_profile_release.py b/libs/cua-driver/hyprland-plugin/packaging/release/test_profile_release.py index 7c8487a200..e44153b4e4 100644 --- a/libs/cua-driver/hyprland-plugin/packaging/release/test_profile_release.py +++ b/libs/cua-driver/hyprland-plugin/packaging/release/test_profile_release.py @@ -19,6 +19,10 @@ class ProfileTest(unittest.TestCase): + schema = 1 + source_revision = verify.SOURCE_REVISION + driver_version = verify.DRIVER_VERSION + def setUp(self): self.temporary = tempfile.TemporaryDirectory(prefix="cua-profile-test-") self.addCleanup(self.temporary.cleanup) @@ -27,17 +31,18 @@ def setUp(self): self.files = {"CMakeLists.txt": b"project(cua_hyprland_plugin VERSION 0.1.0 LANGUAGES CXX)\n", "LICENSE.md": b"Synthetic license\n", "verify.py": b"raise SystemExit('historical verifier must never execute')\n", "src/plugin.cpp": b"// synthetic source\n"} - self.manifest = {"schema": 1, "source_revision": verify.SOURCE_REVISION, "driver_version": "0.24.0", - "release_tag": "cua-driver-rs-v0.24.0", "plugin_version": "0.1.0", "architecture": "x86_64", + self.stem = f"cua-hyprland-plugin-{self.driver_version}-{self.source_revision}" + self.manifest = {"schema": 1, "source_revision": self.source_revision, "driver_version": self.driver_version, + "release_tag": "cua-driver-rs-v" + self.driver_version, "plugin_version": "0.1.0", "architecture": "x86_64", "native_certified": False, "cmake_options": verify.OPTIONS, "hyprland_version": "0.56.2", "hyprland_package": "0.56.2-1", "compiler_version": "16.1.1 20260728", "compiler_comment": "GCC: (GNU) 16.1.1 20260728", "files": {name: verify.sha256(data) for name, data in self.files.items()}} self.files["SOURCE-PROVENANCE.json"] = verify.json_bytes(self.manifest) - self.archive = self.root / (verify.STEM + ".tar.gz") - self.archive.write_bytes(bundle.deterministic_archive({verify.STEM + "/" + name: data for name, data in self.files.items()})) - self.profile = {"schema": 1, "profile_id": "synthetic-native", "kit_version": "1.0.0", "package_release": 2, - "architecture": "x86_64", "source": {"revision": verify.SOURCE_REVISION, "driver_version": "0.24.0", + self.archive = self.root / (self.stem + ".tar.gz") + self.archive.write_bytes(bundle.deterministic_archive({self.stem + "/" + name: data for name, data in self.files.items()})) + self.profile = {"schema": self.schema, "profile_id": "synthetic-native", "kit_version": "1.0.0", "package_release": 2, + "architecture": "x86_64", "source": {"revision": self.source_revision, "driver_version": self.driver_version, "archive_sha256": verify.digest(self.archive), "manifest_sha256": verify.sha256(self.files["SOURCE-PROVENANCE.json"])}, "hyprland": {"package_version": "0.56.2-2", "header_version": "0.56.2", "headers_sha256": "d" * 64, "sha256": "a" * 64}, "compiler": {"version": "16.2.1 20260810", "comment": "GCC: (GNU) 16.2.1 20260810", "sha256": "b" * 64}, @@ -73,7 +78,7 @@ def test_deterministic_separate_kit_and_unchanged_archive(self): self.assertEqual((kit / self.archive.name).read_bytes(), self.archive.read_bytes()) self.assertEqual((kit / "SOURCE-PROVENANCE.json").read_bytes(), self.files["SOURCE-PROVENANCE.json"]) self.assertFalse(metadata["native_certified"]) - self.assertEqual(metadata["source"]["revision"], verify.SOURCE_REVISION) + self.assertEqual(metadata["source"]["revision"], self.source_revision) self.assertNotEqual(metadata["source"]["revision"], metadata["tooling_revision"]) verify.verify_kit(kit, verify.digest(kit / "KIT-PROVENANCE.json"), complete=True) subprocess.run(["bash", "-n", str(kit / "PKGBUILD")], check=True) @@ -100,7 +105,7 @@ def test_generation_refuses_wrong_source_dirty_tooling_and_existing_output(self) verify.verify_archive(self.archive, self.profile) def test_profile_schema_and_no_silent_certification(self): - for field, value in (("native_certified", True), ("schema", 2), ("package_release", True), ("profile_id", "a';false"), ("architecture", "aarch64")): + for field, value in (("native_certified", True), ("schema", 3), ("package_release", True), ("profile_id", "a';false"), ("architecture", "aarch64")): candidate = copy.deepcopy(self.profile) candidate[field] = value with self.subTest(field=field), self.assertRaises(ValueError): @@ -137,11 +142,11 @@ def test_archive_refuses_links_traversal_duplicates_and_missing_files(self): for name, data in self.files.items(): if variant == "missing" and name == "verify.py": continue - info = tarfile.TarInfo(verify.STEM + "/" + name) + info = tarfile.TarInfo(self.stem + "/" + name) info.size = len(data) contents.addfile(info, io.BytesIO(data)) if variant != "missing": - info = tarfile.TarInfo(verify.STEM + "/" + {"symlink": "link", "traversal": "../escape", "duplicate": "verify.py"}[variant]) + info = tarfile.TarInfo(self.stem + "/" + {"symlink": "link", "traversal": "../escape", "duplicate": "verify.py"}[variant]) if variant == "symlink": info.type, info.linkname = tarfile.SYMTYPE, "verify.py" contents.addfile(info) @@ -189,13 +194,13 @@ def test_recipe_tamper_refused_before_execution_and_tests_mandatory(self): def test_profile_lifecycle_kit_preserves_source_identity(self): _, kit, metadata = self.generate() - result = lifecycle.verify_profile_kit(kit, verify.SOURCE_REVISION, verify.DRIVER_VERSION, verify.digest(kit / "KIT-PROVENANCE.json")) + result = lifecycle.verify_profile_kit(kit, self.source_revision, self.driver_version, verify.digest(kit / "KIT-PROVENANCE.json")) self.assertEqual(result[0], self.manifest) self.assertEqual(result[2], self.profile) self.assertEqual(result[3], metadata) (kit / "build").mkdir() with self.assertRaisesRegex(ValueError, "fresh complete"): - lifecycle.verify_profile_kit(kit, verify.SOURCE_REVISION, verify.DRIVER_VERSION, verify.digest(kit / "KIT-PROVENANCE.json")) + lifecycle.verify_profile_kit(kit, self.source_revision, self.driver_version, verify.digest(kit / "KIT-PROVENANCE.json")) def test_reviewed_recipe_reconstruction_refuses_changed_recipe_and_checksums(self): _, kit, _ = self.generate() @@ -205,7 +210,7 @@ def test_reviewed_recipe_reconstruction_refuses_changed_recipe_and_checksums(sel sums = kit / "SHA256SUMS" sums.write_text(sums.read_text().replace(original, verify.digest(recipe))) with self.assertRaisesRegex(ValueError, "recipe differs"): - lifecycle.verify_profile_kit(kit, verify.SOURCE_REVISION, verify.DRIVER_VERSION, verify.digest(kit / "KIT-PROVENANCE.json")) + lifecycle.verify_profile_kit(kit, self.source_revision, self.driver_version, verify.digest(kit / "KIT-PROVENANCE.json")) def test_profile_package_payload_and_provenance(self): _, _, metadata = self.generate() @@ -217,7 +222,7 @@ def test_profile_package_payload_and_provenance(self): lifecycle.BUILD: verify.json_bytes(build), lifecycle.PROFILE: verify.json_bytes(self.profile), lifecycle.KIT: verify.json_bytes(metadata), lifecycle.VERIFIER: (HERE / "profile_verify.py").read_bytes()} names = list(data) + [".PKGINFO", ".BUILDINFO", ".MTREE"] - info = "pkgname = cua-hyprland-plugin\npkgver = 0.24.0-2\narch = x86_64\ndepend = hyprland=0.56.2-2\ndepend = gcc-libs=16.2.1-1\ndepend = python>=3.11\ndepend = binutils\n" + info = f"pkgname = cua-hyprland-plugin\npkgver = {self.driver_version}-2\narch = x86_64\ndepend = hyprland=0.56.2-2\ndepend = gcc-libs=16.2.1-1\ndepend = python>=3.11\ndepend = binutils\n" with mock.patch.object(lifecycle, "run", side_effect=lambda command: subprocess.CompletedProcess(command, 0, "\n".join(names) if "-tf" in command else info, "")), mock.patch.object(lifecycle.subprocess, "check_output", side_effect=lambda command: data[command[-1]]): self.assertEqual(lifecycle.package_payload(self.root / "package", self.manifest, self.profile, metadata), {name: verify.sha256(value) for name, value in data.items()}) for name in (lifecycle.PROFILE, lifecycle.KIT, lifecycle.VERIFIER): @@ -288,7 +293,7 @@ def fake_pacman(command, **kwargs): installed.remove(root) elif operation[0] == "-Q": code = 0 if root in installed else 1 - output = "cua-hyprland-plugin 0.24.0-2" if code == 0 else "" + output = f"cua-hyprland-plugin {self.driver_version}-2" if code == 0 else "" return subprocess.CompletedProcess(command, code, output, "") with mock.patch.object(lifecycle, "run", side_effect=fake_pacman): @@ -301,7 +306,81 @@ def fake_pacman(command, **kwargs): self.assertIn(f"pkgver = {version}\n".encode(), archive.extractfile(".PKGINFO").read()) +class Schema2ProfileTest(ProfileTest): + schema = 2 + source_revision = "c" * 40 + driver_version = "0.26.0" + + def test_schema1_stays_locked_to_original_source(self): + for field, value in (("revision", self.source_revision), ("driver_version", self.driver_version)): + profile = copy.deepcopy(self.profile) + profile["schema"] = 1 + profile["source"].update(revision=verify.SOURCE_REVISION, driver_version=verify.DRIVER_VERSION) + profile["source"][field] = value + with self.subTest(field=field), self.assertRaisesRegex(ValueError, "original Driver"): + verify.validate_profile(profile) + + def test_explicit_source_identity_and_hashes_are_required(self): + for field, values in { + "revision": ["c" * 39, "C" * 40, "../source", "c" * 40 + "\n", None], + "driver_version": ["0.26.0-rc1", "../../other", "0.26.0';false", "0.26.0\n", None], + "archive_sha256": ["", "A" * 64, None], "manifest_sha256": ["", "a" * 63, None], + }.items(): + for value in values: + profile = copy.deepcopy(self.profile) + profile["source"][field] = value + with self.subTest(field=field, value=value), self.assertRaises(ValueError): + verify.source_stem(profile) + for field in self.profile["source"]: + profile = copy.deepcopy(self.profile) + del profile["source"][field] + with self.subTest(missing=field), self.assertRaises(ValueError): + verify.validate_profile(profile) + + def test_manifest_substitution_refused_even_with_selected_digest(self): + changes = {"source_revision": "d" * 40, "driver_version": "0.25.0", "release_tag": "cua-driver-rs-v0.25.0", + "schema": 2, "native_certified": True, "cmake_options": {**verify.OPTIONS, "CUA_HYPRLAND_INPUT_TRACE": "ON"}, + "plugin_version": "0.2.0", "architecture": "aarch64", "hyprland_version": "0.57.0", + "hyprland_package": "0.56.2-2", "compiler_version": "16.2.1 20260810", "compiler_comment": "other"} + for field, value in changes.items(): + manifest = {**self.manifest, field: value} + data = verify.json_bytes(manifest) + profile = copy.deepcopy(self.profile) + profile["source"]["manifest_sha256"] = verify.sha256(data) + with self.subTest(field=field), self.assertRaisesRegex(ValueError, "provenance mismatch"): + verify.source_manifest(data, profile) + for manifest in ({**self.manifest, "extra": True}, {k: v for k, v in self.manifest.items() if k != "release_tag"}): + data = verify.json_bytes(manifest) + profile["source"]["manifest_sha256"] = verify.sha256(data) + with self.assertRaisesRegex(ValueError, "manifest fields"): + verify.source_manifest(data, profile) + + def test_archive_prefix_and_reviewed_digest_prevent_source_substitution(self): + original_digest = verify.digest(self.archive) + wrong = bundle.deterministic_archive({verify.STEM + "/" + name: data for name, data in self.files.items()}) + self.archive.write_bytes(wrong) + with self.assertRaisesRegex(ValueError, "archive checksum"): + verify.verify_archive(self.archive, self.profile) + self.profile["source"]["archive_sha256"] = verify.digest(self.archive) + with self.assertRaisesRegex(ValueError, "archive member"): + verify.verify_archive(self.archive, self.profile) + self.assertNotEqual(original_digest, self.profile["source"]["archive_sha256"]) + + def test_recipe_and_lifecycle_bind_new_source_identity(self): + _, kit, _ = self.generate() + recipe = (kit / "PKGBUILD").read_text() + self.assertIn("pkgver=" + self.driver_version, recipe) + self.assertIn(self.stem, recipe) + self.assertNotIn(verify.STEM, recipe) + for revision, version in ((verify.SOURCE_REVISION, self.driver_version), (self.source_revision, verify.DRIVER_VERSION)): + with self.subTest(revision=revision, version=version), self.assertRaisesRegex(ValueError, "source revision/version"): + lifecycle.verify_profile_kit(kit, revision, version, verify.digest(kit / "KIT-PROVENANCE.json")) + + class NativeProfileTest(unittest.TestCase): + schema = ProfileTest.schema + source_revision = ProfileTest.source_revision + driver_version = ProfileTest.driver_version generate = ProfileTest.generate source = ProfileTest.source From e4dfabbe0ceb95391f3e7cfd476bda5a85922ea4 Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Thu, 10 Sep 2026 04:11:53 -0500 Subject: [PATCH 23/27] test(cua-driver): separate pixel selection from menu grounding --- .../tests/production_pointer_grounding.py | 28 ++++++++++-- .../production_pointer_grounding_test.py | 44 +++++++++++++++++++ 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/libs/cua-driver/hyprland-plugin/tests/production_pointer_grounding.py b/libs/cua-driver/hyprland-plugin/tests/production_pointer_grounding.py index 2e3c9bbbf9..39a05b4398 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production_pointer_grounding.py +++ b/libs/cua-driver/hyprland-plugin/tests/production_pointer_grounding.py @@ -7,8 +7,7 @@ import math import re -from production_app_smoke import (GroundingUnavailable, calc_formula_selection, - inkscape_selection_command, rows) +from production_app_smoke import GroundingUnavailable, calc_formula_selection, rows STAGES = { @@ -210,6 +209,29 @@ def blue_rectangle(snapshot, image): 'center': [(left + right) // 2, (top + bottom) // 2]} +def inkscape_unselected(snapshot): + """Prove the fixture's positive unselected status, not a keyboard command. + + Pixel selection does not use Edit > Select All. Native background snapshots + can omit that menu while exposing the object and its exact unselected status. + Both projections must identify the object; missing status is never treated + as evidence that the object is unselected. + """ + objects = [row for row in rows(snapshot) if row.get('role') == 'table cell' + and row.get('label') == 'smoke-rectangle'] + if len(objects) != 1 or objects[0].get('enabled') is not True: + return False + lines = [line.strip() for line in snapshot.get('tree_markdown', '').splitlines()] + status = ('- label = "No objects selected. Click, Shift+click, Alt+scroll mouse ' + 'on top of objects, or drag around objects to select."') + conflicts = ('- label = "Rectangle in root. Click selection again to toggle scale/rotation handles."', + '- label = "Center of transformation: drag to reposition; scaling, rotation ' + 'and skew with Shift also uses this center"') + prefix = f'- [{objects[0].get("element_index")}] table cell "smoke-rectangle" ' + return (lines.count(status) == 1 and sum(line.startswith(prefix) for line in lines) == 1 + and not any(line in conflicts for line in lines)) + + def action(snapshot, image, app, stage): checked_snapshot(snapshot, image, app) if stage not in STAGES[app]: @@ -229,7 +251,7 @@ def action(snapshot, image, app, stage): else: oracle['rectangle'] = blue_rectangle(snapshot, image) if stage == 'click_rectangle': - if not inkscape_selection_command(snapshot, rows(snapshot)): + if not inkscape_unselected(snapshot): raise GroundingUnavailable('click proof needs the unselected synthetic rectangle') oracle['geometry'] = None else: diff --git a/libs/cua-driver/hyprland-plugin/tests/production_pointer_grounding_test.py b/libs/cua-driver/hyprland-plugin/tests/production_pointer_grounding_test.py index 7c3f1cd327..1351876b81 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production_pointer_grounding_test.py +++ b/libs/cua-driver/hyprland-plugin/tests/production_pointer_grounding_test.py @@ -213,6 +213,50 @@ def test_inkscape_click_requires_an_actual_selection_transition(self): with self.assertRaises(pointer.GroundingUnavailable): pointer.action(*ink(), 'inkscape', 'click_rectangle') + def test_pixel_selection_does_not_require_keyboard_menu_discovery(self): + state, image = ink(False) + state['elements'] = [row for row in state['elements'] if row['role'] == 'table cell'] + state['tree_markdown'] = '\n'.join(line for line in state['tree_markdown'].splitlines() + if 'menu ' not in line and 'menu item ' not in line) + args, oracle = pointer.action(state, image, 'inkscape', 'click_rectangle') + self.assertEqual(args, {'x': 169, 'y': 179}) + self.assertTrue(pointer.verify(*ink(), oracle)['verified']) + # The separate Ctrl+A grounding contract still needs its actual menu. + from production_app_smoke import inkscape_selection_command, rows + self.assertFalse(inkscape_selection_command(state, rows(state))) + + def test_pixel_selection_requires_positive_consistent_unselected_evidence(self): + for failure in ('missing_status', 'duplicate_status', 'missing_object', + 'duplicate_object', 'disabled_object', 'missing_object_line', + 'duplicate_object_line', 'wrong_object_index', 'selected_conflict', 'dialog'): + with self.subTest(failure=failure): + state, image = ink(False) + object_row = next(row for row in state['elements'] if row['role'] == 'table cell') + lines = state['tree_markdown'].splitlines() + if failure == 'missing_status': + lines = [line for line in lines if 'No objects selected.' not in line] + elif failure == 'duplicate_status': + lines.append(next(line for line in lines if 'No objects selected.' in line)) + elif failure == 'missing_object': + state['elements'].remove(object_row) + elif failure == 'duplicate_object': + state['elements'].append(copy.deepcopy(object_row)) + elif failure == 'disabled_object': + object_row['enabled'] = False + elif failure == 'missing_object_line': + lines = [line for line in lines if 'table cell' not in line] + elif failure == 'duplicate_object_line': + lines.append(next(line for line in lines if 'table cell' in line)) + elif failure == 'wrong_object_index': + object_row['element_index'] = 999 + elif failure == 'selected_conflict': + lines.append('- label = "Rectangle in root. Click selection again to toggle scale/rotation handles."') + else: + state['elements'].append({'role': 'dialog'}) + state['tree_markdown'] = '\n'.join(lines) + with self.assertRaises(pointer.GroundingUnavailable): + pointer.action(state, image, 'inkscape', 'click_rectangle') + def test_inkscape_drag_and_scroll_need_pixels_and_semantics_to_agree(self): args, oracle = pointer.action(*ink(), 'inkscape', 'move_rectangle') self.assertEqual([args['from_x'], args['from_y']], [145, 165]) From 136ceee6850ee08a2c1c092e55478196a4db8f63 Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Thu, 10 Sep 2026 04:26:47 -0500 Subject: [PATCH 24/27] test(cua-driver): verify inert target-recovery retirement --- .../tests/production_target_lifetime_proof.py | 64 +++++++-- .../production_target_lifetime_proof_test.py | 121 +++++++++++++++++- 2 files changed, 171 insertions(+), 14 deletions(-) diff --git a/libs/cua-driver/hyprland-plugin/tests/production_target_lifetime_proof.py b/libs/cua-driver/hyprland-plugin/tests/production_target_lifetime_proof.py index 276f686abd..f6062a0e71 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production_target_lifetime_proof.py +++ b/libs/cua-driver/hyprland-plugin/tests/production_target_lifetime_proof.py @@ -349,6 +349,42 @@ def cleanup_trace(before, after, *, stopped=False): return isolation(after, stopped=stopped) +def verify_recovery_retirement(before, after, lane, *, retired=True): + """EOF releases capacity, preserving only the captured inert recovery hover.""" + assert type(lane) is int and lane in (1, 2), 'invalid traced recovery lane' + old = lanes(before, cleared=True, allow_passive=True) + new = lanes(after, cleared=True, allow_passive=True) + assert set(old) == set(new) == {0, 1} + for key, row in old.items(): + assert row['reserved'] is (key == lane - 1), 'unexpected recovery reservation' + assert all(type(row.get(k)) is int and row[k] >= 0 + for k in ('seat_resources', 'pointer_resources', 'keyboard_resources')) + assert all(type(new[key].get(k)) is int and new[key][k] >= 0 + for k in ('seat_resources', 'pointer_resources', 'keyboard_resources')) + if key != lane - 1: + assert row['pointer_focus'] is False, 'unexpected recovery pointer lane' + assert type(new[key]['reserved']) is bool, 'invalid retirement reservation' + expected = dict(row) + if key == lane - 1: + expected['reserved'] = False if retired else new[key]['reserved'] + assert new[key] == expected, 'recovery cleanup changed lane state or retained capacity' + return {'result': 'verified', 'lane': lane, 'unreserved': retired, + 'input_authority': False, 'parked_pointer_focus': old[lane - 1]['pointer_focus'], + 'lane_state': 'unchanged_except_released_recovery_reservation'} + + +def await_recovery_retirement(fault, recovery, runtimes): + assert recovery['result'] == 'verified', 'recovery was not verified' + assert runtimes and all(c.process.poll() is not None for c in runtimes), 'runtime still alive' + def sample(): + current = fault.status() + verify_recovery_retirement(recovery['status'], current, recovery['lane'], retired=False) + return current if all(row['reserved'] is False for row in lanes(current).values()) else None + current = wait_for(sample, timeout=3) + return {'status': current, 'all_runtimes_closed': True, 'observed_ns': time.monotonic_ns(), + 'verification': verify_recovery_retirement(recovery['status'], current, recovery['lane'])} + + def recover(client, observer, victim, fault, trace, boundary, lane, guard, save, result): assert fault.destroyed and victim.process.poll() is not None assert victim.process.pid not in assert_distinct_runtimes([client, observer]) @@ -393,7 +429,8 @@ def recover(client, observer, victim, fault, trace, boundary, lane, guard, save, assert all(r[2] in ('agent_admitted', 'agent_approved', 'agent_action_end', 'pointer_enter', 'pointer_motion', 'pointer_button', 'pointer_leave', 'keyboard_leave') for r in synthetic) assert [r[6] for r in synthetic if r[2] == 'pointer_button'] == [1, 0] - recovered = lanes(fault.status()) + recovery_status = fault.status() + recovered = lanes(recovery_status, cleared=True, allow_passive=True) for key, row in recovered.items(): assert row['held_button'] == row['held_keys'] == 0 assert all(row[k] is False for k in ('drag_active', 'lease_active', 'keyboard_focus')) @@ -402,6 +439,8 @@ def recover(client, observer, victim, fault, trace, boundary, lane, guard, save, assert row['pointer_focus'] is False result['continuous_isolation'] = isolation(page) guard() + result.update(status=recovery_status, lane=lane) + save('recovery-status.json', recovery_status) result['result'] = 'verified' return page @@ -507,12 +546,20 @@ def launch(name): operations.append(('shutdown_pool', lambda: pool.shutdown(wait=False, cancel_futures=True))) if fault: operations.append(('preserve_fault', lambda: save('fault.json', fault.record))) + if observer: + operations.append(('close_observer', lambda: close_owned(observer))) if trace: def finish(): - trace.exchange('TRACE_STOP') - stopped = trace.collect() - save('trace.json', stopped) - report['continuous_isolation'] = cleanup_trace(prefix, stopped, stopped=True) if prefix else isolation(stopped, stopped=True) + try: + if report['recovery']['result'] == 'verified': + report['recovery_cleanup'] = await_recovery_retirement(fault, report['recovery'], + [*clients, observer]) + save('recovery-cleanup.json', report['recovery_cleanup']) + finally: + trace.exchange('TRACE_STOP') + stopped = trace.collect() + save('trace.json', stopped) + report['continuous_isolation'] = cleanup_trace(prefix, stopped, stopped=True) if prefix else isolation(stopped, stopped=True) guard() primary_after = wm() assert primary_after == primary_before @@ -521,7 +568,10 @@ def finish(): save('final-primary.json', {'primary': primary_after, 'foreground': current}) final = fault.status() save('final-status.json', final) - assert all(r['reserved'] is False for r in lanes(final, cleared=True).values()) + if report['recovery']['result'] == 'verified': + verify_recovery_retirement(report['recovery']['status'], final, report['recovery']['lane']) + else: + assert all(r['reserved'] is False for r in lanes(final, cleared=True).values()) operations.extend([('finish_trace', finish), ('close_trace', trace.close)]) if fault: operations.append(('close_pidfd', fault.close)) @@ -532,8 +582,6 @@ def release(): stop_process(grab) wait_for(lambda: not state(args.foreground_journal)['held'], timeout=3) operations.append(('release_primary', release)) - if observer: - operations.append(('close_observer', lambda: close_owned(observer))) errors = cleanup_all(operations) save('cleanup.json', {'errors': errors}) if errors: diff --git a/libs/cua-driver/hyprland-plugin/tests/production_target_lifetime_proof_test.py b/libs/cua-driver/hyprland-plugin/tests/production_target_lifetime_proof_test.py index 766f6427b6..568a6e8a5f 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production_target_lifetime_proof_test.py +++ b/libs/cua-driver/hyprland-plugin/tests/production_target_lifetime_proof_test.py @@ -2,6 +2,7 @@ from contextlib import ExitStack from copy import deepcopy import hashlib +from itertools import product import json import os from pathlib import Path @@ -42,6 +43,14 @@ def statuses(): return before, after +def recovery_status(lane=1, pointer=True): + observed = status() + for row in observed['input']['lanes']: + row.update(seat_resources=2, pointer_resources=2, keyboard_resources=2) + observed['input']['lanes'][lane - 1].update(reserved=True, pointer_focus=pointer, dispatches=1) + return observed + + def record(): before, after = statuses() return {'result': 'observed', 'target': plan()['agents'][0]['target'], 'lane': 1, @@ -296,13 +305,110 @@ def test_gone_requires_pidfd_exit_and_old_address_absence(self): class RecoveryTests(unittest.TestCase): + def test_final_cleanup_preserves_only_captured_recovery_hover(self): + for lane in (1, 2): + for pointer in (True, False): + before = recovery_status(lane, pointer) + after = deepcopy(before) + after['input']['lanes'][lane - 1]['reserved'] = False + with self.subTest(lane=lane, pointer=pointer): + result = proof.verify_recovery_retirement(before, after, lane) + self.assertTrue(result['unreserved']) + self.assertFalse(result['input_authority']) + self.assertEqual(result['parked_pointer_focus'], pointer) + if pointer: + with self.assertRaises(AssertionError): + proof.lanes(after, cleared=True) + + def test_final_cleanup_rejects_authority_wrong_lane_and_identity_drift(self): + before = recovery_status() + after = deepcopy(before) + after['input']['lanes'][0]['reserved'] = False + for key in (0, 1): + for field, value in (('reserved', True), ('reserved', 0), ('held_button', 272), + ('held_keys', 1), ('drag_active', True), ('lease_active', True), + ('keyboard_focus', True), ('epoch', 'f' * 32), ('desktop_generation', 2), + ('dispatches', 9), ('seat_resources', 1), ('pointer_resources', 1), + ('keyboard_resources', 1), ('pointer_focus', key == 1)): + changed = deepcopy(after) + changed['input']['lanes'][key][field] = value + with self.subTest(key=key, field=field, value=value), self.assertRaises(AssertionError): + proof.verify_recovery_retirement(before, changed, 1) + for lane in (0, 2, 3, True): + with self.subTest(lane=lane), self.assertRaises(AssertionError): + proof.verify_recovery_retirement(before, after, lane) + for field, value in (('pointer_focus', True), ('reserved', True)): + changed = deepcopy(before) + changed['input']['lanes'][1][field] = value + with self.subTest(captured_field=field), self.assertRaises(AssertionError): + proof.verify_recovery_retirement(changed, after, 1) + with self.assertRaises(AssertionError): + proof.verify_recovery_retirement(recovery_status(pointer=False), after, 1) + + def test_final_retirement_wait_requires_closed_runtimes_and_clean_samples(self): + before = recovery_status() + after = deepcopy(before) + after['input']['lanes'][0]['reserved'] = False + recovery = {'result': 'verified', 'status': before, 'lane': 1} + runtimes = [Mock(process=Mock(poll=Mock(return_value=0))) for _ in range(3)] + fault = SimpleNamespace(status=Mock(side_effect=[before, after])) + def wait(sample, timeout): + self.assertEqual(timeout, 3) + self.assertIsNone(sample()) + return sample() + with patch.object(proof, 'wait_for', side_effect=wait): + result = proof.await_recovery_retirement(fault, recovery, runtimes) + self.assertTrue(result['all_runtimes_closed']) + self.assertTrue(result['verification']['unreserved']) + for runtime in runtimes: + runtime.process.poll.return_value = None + with self.assertRaisesRegex(AssertionError, 'runtime still alive'): + proof.await_recovery_retirement(fault, recovery, runtimes) + runtime.process.poll.return_value = 0 + with self.assertRaisesRegex(AssertionError, 'not verified'): + proof.await_recovery_retirement(fault, {**recovery, 'result': 'unproven'}, runtimes) + for field, value in (('lease_active', True), ('held_button', 272), ('held_keys', 1), + ('drag_active', True), ('keyboard_focus', True), ('dispatches', 2)): + pending = deepcopy(before) + pending['input']['lanes'][0][field] = value + fault.status = Mock(return_value=pending) + with self.subTest(field=field), patch.object(proof, 'wait_for', side_effect=wait), \ + self.assertRaises(AssertionError): + proof.await_recovery_retirement(fault, recovery, runtimes) + fault.status = Mock(return_value=before) + def expires(sample, timeout): + self.assertEqual(timeout, 3) + self.assertIsNone(sample()) + raise AssertionError('bounded evidence wait expired') + with patch.object(proof, 'wait_for', side_effect=expires), self.assertRaisesRegex(AssertionError, 'expired'): + proof.await_recovery_retirement(fault, recovery, runtimes) + + def test_final_cleanup_trace_rejects_new_input_and_primary_drift(self): + rows = DESTROYED + [(12, 'agent_admitted', 1, 0), (13, 'pointer_button', 1, 1), + (14, 'pointer_button', 1, 0), (15, 'agent_action_end', 1, 0)] + prefix = trace(rows) + self.assertEqual(proof.cleanup_trace(prefix, proof.stopped_prefix(prefix), stopped=True)['result'], 'passed') + for lane, kind, value in ((1, 'pointer_motion', 0), (2, 'pointer_motion', 0), + (1, 'pointer_button', 1), (1, 'keyboard_key', 1), (1, 'agent_admitted', 0), + (0, 'pointer_focus', 0), (0, 'pointer_button', 0)): + stopped = proof.stopped_prefix(trace(rows + [(16, kind, lane, value)])) + with self.subTest(lane=lane, kind=kind), self.assertRaises(AssertionError): + proof.cleanup_trace(prefix, stopped, stopped=True) + stopped = proof.stopped_prefix(trace(rows + [(16, 'cursor', 0, 0), (17, 'cursor', 0, 0)])) + stopped['events'][-3][3] += 2 + with self.assertRaises(AssertionError): + proof.cleanup_trace(prefix, stopped, stopped=True) + def test_one_fresh_distinct_action_unknown_never_replayed(self): - for failure in (None, 'alive', 'reused_runtime', 'stale', 'unknown', 'bad_effect', 'same_snapshot', - 'same_artifact', 'cached', 'before_return', 'same_runtime', 'dead_after'): - with self.subTest(failure=failure), ExitStack() as stack: + for app, failure in product(('calc', 'inkscape'), (None, 'alive', 'reused_runtime', 'stale', + 'unknown', 'bad_effect', 'same_snapshot', 'same_artifact', 'cached', 'before_return', + 'same_runtime', 'dead_after')): + with self.subTest(app=app, failure=failure), ExitStack() as stack: candidate = plan() - recovered_status = status() - recovered_status['input']['lanes'][0]['reserved'] = True + if app == 'inkscape': + candidate['agents'][0].update(app=app, pointer_stage='move_rectangle') + candidate['recovery']['agent'].update(app=app, pointer_stage='click_rectangle') + recovered_status = recovery_status() fault = SimpleNamespace(destroyed=True, spec=candidate['agents'][0], fresh=candidate['recovery']['agent'], guard=Mock(), status=Mock(return_value=recovered_status)) client = Mock(process=Mock(pid=100 if failure == 'reused_runtime' else 101, poll=Mock(return_value=None))) @@ -324,7 +430,7 @@ def test_one_fresh_distinct_action_unknown_never_replayed(self): elif failure == 'before_return': after['proof_observation_started_ns'] = 102 prepared = {'target': fault.fresh['target'], 'snapshot': before, 'prepared_ns': 100, - 'arguments': {'x': 20, 'y': 30}, 'oracle': {'stage': 'click_b2'}} + 'arguments': {'x': 20, 'y': 30}, 'oracle': {'stage': fault.fresh['pointer_stage']}} stack.enter_context(patch.object(proof, 'prepare_drag', return_value=prepared)) stack.enter_context(patch.object(proof, 'grounded_snapshot', return_value=after)) stack.enter_context(patch.object(proof.time, 'monotonic_ns', side_effect= @@ -341,6 +447,9 @@ def test_one_fresh_distinct_action_unknown_never_replayed(self): else: proof.recover(client, observer, victim, fault, collector, trace(DESTROYED), 1, Mock(), Mock(), result) self.assertEqual(result['result'], 'verified') + self.assertEqual(result['status'], recovered_status) + self.assertTrue(result['status']['input']['lanes'][0]['pointer_focus']) + self.assertEqual(result['lane'], 1) clicks = [call for call in client.tool.call_args_list if call.args[0] == 'click'] self.assertEqual(len(clicks), 0 if failure in ('alive', 'reused_runtime', 'stale', 'same_runtime') else 1) if clicks: From 43682d8f2353bb0422f8738ada1813e06a2f52a1 Mon Sep 17 00:00:00 2001 From: Francesco Bonacci <195596869+f-trycua@users.noreply.github.com> Date: Thu, 10 Sep 2026 05:26:00 -0500 Subject: [PATCH 25/27] docs(cua-driver): describe reviewed profile source selection --- .../packaging/release/PROFILE-USAGE.md | 20 +++++++++++-------- .../packaging/release/README.md | 8 +++++--- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/libs/cua-driver/hyprland-plugin/packaging/release/PROFILE-USAGE.md b/libs/cua-driver/hyprland-plugin/packaging/release/PROFILE-USAGE.md index 121b028a47..36696c8b9c 100644 --- a/libs/cua-driver/hyprland-plugin/packaging/release/PROFILE-USAGE.md +++ b/libs/cua-driver/hyprland-plugin/packaging/release/PROFILE-USAGE.md @@ -1,9 +1,11 @@ # Build a reviewed native-profile package -This kit rebuilds the unchanged Driver 0.24.0 plugin source for one explicitly -reviewed native profile. The historical archive and its embedded manifest and -verifier retain their original bytes. The recipe uses `profile_verify.py` from -this kit. It does not invoke the historical verifier or rewrite source files. +This kit builds the plugin source selected in `PROFILE.json` for one explicitly +reviewed native profile. Schema 1 selects the original Driver 0.24.0 source; +schema 2 can select a separately reviewed source revision and Driver version. +The selected archive and its embedded manifest and verifier retain their +original bytes. The recipe uses `profile_verify.py` from this kit. It does not +invoke the embedded verifier or rewrite source files. The kit, source, and native profile have separate identities. `PROFILE.json` contains the profile ID, kit version, package release, source checksums, and @@ -38,7 +40,7 @@ changes runtime search paths or the desktop environment. The recipe checks exact native package versions, compositor and compiler bytes, GCC version/date and emitted ELF comment, the package-owned Hyprland header tree, -and matching shared-runtime bytes. The unchanged source requires Hyprland 0.56.2 +and matching shared-runtime bytes. These profiles require Hyprland 0.56.2 headers. CMake enables production input, disables experimental input and tracing, and builds the bundled tests. Packaging runs CTest even with `--nocheck` or `--repackage`; skipping makepkg integrity checks does not skip the recipe checks. @@ -65,12 +67,14 @@ build-selection checks, not a sandbox for arbitrary build environments. ## Qualify package transactions In a disposable matching Arch environment, with ordinary-user build tools and -previously authorized noninteractive sudo for isolated ALPM roots, run: +previously authorized noninteractive sudo for isolated ALPM roots, run the +following command. Replace `SOURCE_REVISION` and `DRIVER_VERSION` with the exact +`source.revision` and `source.driver_version` from the reviewed `PROFILE.json`: ```sh python3 lifecycle.py --kit . \ - --revision 4b3396d9fe4bd3cf723b0eb8db83c18a8764b520 \ - --driver-version 0.24.0 --kit-sha256 REVIEWED_KIT_PROVENANCE_SHA256 \ + --revision SOURCE_REVISION \ + --driver-version DRIVER_VERSION --kit-sha256 REVIEWED_KIT_PROVENANCE_SHA256 \ --output NEW_EVIDENCE_DIRECTORY ``` diff --git a/libs/cua-driver/hyprland-plugin/packaging/release/README.md b/libs/cua-driver/hyprland-plugin/packaging/release/README.md index 4de8b6fa51..38c590ee2a 100644 --- a/libs/cua-driver/hyprland-plugin/packaging/release/README.md +++ b/libs/cua-driver/hyprland-plugin/packaging/release/README.md @@ -1,8 +1,10 @@ # Pinned source release -For a separately reviewed native profile around the unchanged Driver 0.24.0 -archive, see [profile-based rebuilds](profile-contract.md) and -[profile kit usage](PROFILE-USAGE.md). This legacy generator remains unchanged. +For separately reviewed native profiles, see +[profile-based rebuilds](profile-contract.md) and +[profile kit usage](PROFILE-USAGE.md). Schema 1 preserves the original Driver +0.24.0 source archive; schema 2 supports a separately reviewed source revision. +The source archive generator described below remains separate from profile kits. Prepare a profile kit only after committing the packaging tooling and reviewing the measured profile: From 90b594d2af1041963a1ab2cf2adb3de71d647296 Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Thu, 10 Sep 2026 05:59:03 -0500 Subject: [PATCH 26/27] docs(cua-driver): align primary observer duplicate contract --- .../tests/production-inkscape-profile.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/libs/cua-driver/hyprland-plugin/tests/production-inkscape-profile.md b/libs/cua-driver/hyprland-plugin/tests/production-inkscape-profile.md index 56d265551a..d735b3965d 100644 --- a/libs/cua-driver/hyprland-plugin/tests/production-inkscape-profile.md +++ b/libs/cua-driver/hyprland-plugin/tests/production-inkscape-profile.md @@ -154,9 +154,16 @@ fails qualification. Raw begin/end journal and wire evidence are retained. Baseline wire evidence must show one primary pointer and keyboard on the same surface, a held left button, and no held keyboard keys/modifiers. During the -parked interval, any pointer motion (including return to the original position), -enter/leave, button, axis, key, keyboard-focus, seat-capability, or corresponding -journal focus/grab/input transition fails. The foreground client counters and +parked interval, an exact same-position `wl_pointer.motion` notification on the +same primary object may pass only with unchanged focus, held input, and +foreground interaction. Retain and count these duplicates; they do not certify +the negative control. Any displacement, including an excursion and return, +foreign-pointer motion, enter/leave, button, axis, key, keyboard-focus, +seat-capability, or corresponding journal focus/grab/input transition fails. +GTK journal motion events and counter changes still fail because they do not +independently establish device identity and a baseline. See the +[duplicate-notification contract](production-proof.md#inkscape-only-environment-profile) +for the complete constraints. The foreground client counters and held-button state must remain unchanged, and the independent compositor cursor/focus/workspace endpoint checks must also match. This establishes client-observed primary continuity under the implicit held-button grab; it From 67f89ceb47edd973aa748820eadd02ddbfb3d3e4 Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Thu, 10 Sep 2026 06:59:20 -0500 Subject: [PATCH 27/27] build(cua-driver): bind Omarchy profile to released 0.26.1 source --- .../release/profiles/omarchy-stable-20260910.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/libs/cua-driver/hyprland-plugin/packaging/release/profiles/omarchy-stable-20260910.json b/libs/cua-driver/hyprland-plugin/packaging/release/profiles/omarchy-stable-20260910.json index 6e214e47b0..10fd193478 100644 --- a/libs/cua-driver/hyprland-plugin/packaging/release/profiles/omarchy-stable-20260910.json +++ b/libs/cua-driver/hyprland-plugin/packaging/release/profiles/omarchy-stable-20260910.json @@ -1,13 +1,13 @@ { - "schema": 1, + "schema": 2, "profile_id": "omarchy-stable-20260910", - "kit_version": "1.0.0", + "kit_version": "1.1.0", "package_release": 2, "source": { - "revision": "4b3396d9fe4bd3cf723b0eb8db83c18a8764b520", - "driver_version": "0.24.0", - "archive_sha256": "73b65823b3281c027a31cd8f7d9ca9586fe7386ed1eec74174f2e72cea0af643", - "manifest_sha256": "fb5b5710218afecfa54e9803e8e95f4702e8a47ac4108abd06b4f5f3c1032eeb" + "revision": "cc54254464c0c9aebfd6547fe7e4a0ceaf0456d7", + "driver_version": "0.26.1", + "archive_sha256": "47bca9e018f32f4fcfe683f91c7475c60368f3b65d318cc35c1f2de88a4ee9ab", + "manifest_sha256": "54f514664c84e1358a435f29cd6befd5661b0b133d76997191c000b10f021a75" }, "architecture": "x86_64", "hyprland": {