From faa9a0071699e229e147033221f5772c25bd1dd6 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 18 Aug 2026 11:11:53 -0400 Subject: [PATCH] Close Panama recovery race windows --- config/dot/quickshell/scripts/panama-doctor | 191 ++++++++++++++++---- tests/quickshell/panama-doctor-contract.sh | 179 +++++++++++++++--- 2 files changed, 315 insertions(+), 55 deletions(-) diff --git a/config/dot/quickshell/scripts/panama-doctor b/config/dot/quickshell/scripts/panama-doctor index 2e8fa03..9025015 100755 --- a/config/dot/quickshell/scripts/panama-doctor +++ b/config/dot/quickshell/scripts/panama-doctor @@ -5,6 +5,8 @@ from __future__ import annotations import argparse +import ctypes +import errno import json import os import re @@ -23,6 +25,11 @@ from typing import Callable, Literal Status = Literal["ok", "warning", "error", "unconfigured"] Group = Literal["desktop-foundation", "input-media", "integrations", "panama-tools"] ActionKind = Literal["repair", "open", "instructions"] +InhibitorRow = tuple[str, str, str, str, str, str, str, str] + +AT_FDCWD = -100 +RENAME_NOREPLACE = 1 +RENAME_EXCHANGE = 2 @dataclass(frozen=True) @@ -452,10 +459,10 @@ def check_caffeine(config: DoctorConfig) -> Check: result = run_command(("systemd-inhibit", "--list", "--no-pager", "--no-legend"), config) if result.state != "ok": return Check("panama.caffeine", "panama-tools", "Caffeine inhibitor", "warning", "Caffeine inhibitor probe is unavailable.") - inhibitor_pids = parse_caffeine_pids(result.stdout, str(os.getuid())) - if inhibitor_pids is None: + inhibitor_rows = parse_caffeine_rows(result.stdout, str(os.getuid())) + if inhibitor_rows is None: return Check("panama.caffeine", "panama-tools", "Caffeine inhibitor", "warning", "Caffeine inhibitor probe returned an invalid result.") - inhibitors = len(dict.fromkeys(inhibitor_pids)) + inhibitors = len(dict.fromkeys(int(row[3]) for row in inhibitor_rows)) if inhibitors > 1: return Check("panama.caffeine", "panama-tools", "Caffeine inhibitor", "warning", "Duplicate Panama Caffeine inhibitors detected.", Action("repair", "Release duplicate inhibitors")) if inhibitors == 1: @@ -542,29 +549,120 @@ def lexical_link_target(destination: Path) -> Path: return lexical_path(target if target.is_absolute() else destination.parent / target) -def atomic_symlink_replace(destination: Path, source: Path) -> None: - """Install an authored sibling symlink without first removing destination.""" +def renameat2(source: Path, destination: Path, flags: int) -> None: + """Call Linux renameat2 with fixed flags selected by authored code.""" + libc = ctypes.CDLL(None, use_errno=True) + function = getattr(libc, "renameat2", None) + if function is None: + raise OSError(errno.ENOSYS, "renameat2 is unavailable") + function.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint] + function.restype = ctypes.c_int + result = function( + AT_FDCWD, + os.fsencode(source), + AT_FDCWD, + os.fsencode(destination), + flags, + ) + if result != 0: + error = ctypes.get_errno() + raise OSError(error, os.strerror(error), destination) + + +def rename_exchange(source: Path, destination: Path) -> None: + renameat2(source, destination, RENAME_EXCHANGE) + + +def rename_noreplace(source: Path, destination: Path) -> None: + renameat2(source, destination, RENAME_NOREPLACE) + + +def create_symlink_candidate(destination: Path, source: Path) -> Path: + """Create one unpredictable authored sibling candidate symlink.""" for _ in range(32): - temporary = destination.with_name( + candidate = destination.with_name( f".panama-link-{destination.name}-{os.getpid()}-{secrets.token_hex(8)}" ) - created = False try: - os.symlink(source, temporary, target_is_directory=True) - created = True - os.replace(temporary, destination) - return + os.symlink(source, candidate, target_is_directory=True) + return candidate except FileExistsError: continue - finally: - if created: - try: - temporary.unlink() - except FileNotFoundError: - pass raise OSError("Could not allocate an authored temporary link") +def cleanup_candidate(candidate: Path) -> None: + try: + candidate.unlink() + except FileNotFoundError: + pass + + +def install_absent_symlink(destination: Path, source: Path) -> Literal["repaired", "blocked", "failed"]: + candidate = create_symlink_candidate(destination, source) + try: + try: + rename_noreplace(candidate, destination) + except FileExistsError: + return "blocked" + except OSError: + return "failed" + return "repaired" + finally: + cleanup_candidate(candidate) + + +def exchange_owned_symlink( + destination: Path, + source: Path, + authored_sources: frozenset[Path], +) -> Literal["repaired", "blocked", "failed"]: + """Exchange first, then validate the exact object removed from destination.""" + candidate = create_symlink_candidate(destination, source) + exchanged = False + rolled_back = False + try: + try: + rename_exchange(candidate, destination) + exchanged = True + except OSError: + return "failed" + + try: + old_is_authored = candidate.is_symlink() \ + and lexical_link_target(candidate) in authored_sources + except OSError: + old_is_authored = False + if old_is_authored: + cleanup_candidate(candidate) + return "repaired" + + try: + rename_exchange(candidate, destination) + rolled_back = True + except OSError: + # The displaced object remains at the unpredictable candidate path; + # never unlink it when rollback could not restore ownership. + return "failed" + + try: + restored_candidate_is_ours = candidate.is_symlink() \ + and lexical_link_target(candidate) == source + except OSError: + restored_candidate_is_ours = False + if not restored_candidate_is_ours: + return "failed" + cleanup_candidate(candidate) + return "blocked" + finally: + if not exchanged or rolled_back: + try: + if candidate.is_symlink() and lexical_link_target(candidate) == source: + cleanup_candidate(candidate) + except OSError: + pass + + def repair_runtime_links(config: DoctorConfig) -> RepairResult: root = lexical_path(config.root) sources = [(name, lexical_path(config.root / relative_source)) for name, relative_source in RUNTIME_LINK_TARGETS] @@ -591,13 +689,17 @@ def repair_runtime_links(config: DoctorConfig) -> RepairResult: if current_target not in authored_sources: blocked = True continue - atomic_symlink_replace(destination, source) + outcome = exchange_owned_symlink(destination, source, authored_sources) + blocked = blocked or outcome == "blocked" + failed = failed or outcome == "failed" elif destination.exists(): # A regular file or directory is user-owned unless proven # otherwise. Report it, but never replace it. blocked = True else: - atomic_symlink_replace(destination, source) + outcome = install_absent_symlink(destination, source) + blocked = blocked or outcome == "blocked" + failed = failed or outcome == "failed" except OSError: failed = True @@ -618,8 +720,8 @@ def repair_vicinae_commands(config: DoctorConfig) -> RepairResult: return RepairResult("panama.vicinae-commands", True, exit_code, message) -def parse_caffeine_pids(output: str, uid: str) -> list[int] | None: - inhibitor_pids: list[int] = [] +def parse_caffeine_rows(output: str, uid: str) -> list[InhibitorRow] | None: + inhibitor_rows: list[InhibitorRow] = [] for line in output.splitlines(): parts = line.split() if len(parts) < 2 or parts[0] != "Panama" or parts[1] != uid: @@ -632,8 +734,8 @@ def parse_caffeine_pids(output: str, uid: str) -> list[int] | None: continue if not parts[3].isdecimal(): return None - inhibitor_pids.append(int(parts[3])) - return inhibitor_pids + inhibitor_rows.append(tuple(parts)) + return inhibitor_rows def close_pidfds(pidfds: list[int]) -> None: @@ -644,6 +746,31 @@ def close_pidfds(pidfds: list[int]) -> None: pass +def signal_caffeine_pidfds( + pidfds: list[int], + sender: Callable[..., None] | None = None, +) -> Literal["released", "preflight-failed", "incomplete"]: + send = sender or signal.pidfd_send_signal + for pidfd in pidfds: + try: + send(pidfd, 0, None, 0) + except (OSError, ValueError): + return "preflight-failed" + + incomplete = False + for pidfd in pidfds: + try: + send(pidfd, signal.SIGTERM, None, 0) + except ProcessLookupError: + continue + except OSError as error: + if error.errno != errno.ESRCH: + incomplete = True + except ValueError: + incomplete = True + return "incomplete" if incomplete else "released" + + def repair_caffeine(config: DoctorConfig) -> RepairResult: list_command = ("systemd-inhibit", "--list", "--no-pager", "--no-legend") list_exit, output = run_repair_command(list_command, config) @@ -651,10 +778,10 @@ def repair_caffeine(config: DoctorConfig) -> RepairResult: return RepairResult("panama.caffeine", True, list_exit, "Caffeine inhibitors could not be inspected.") uid = str(os.getuid()) - parsed_pids = parse_caffeine_pids(output, uid) - if parsed_pids is None: + inhibitor_rows = parse_caffeine_rows(output, uid) + if inhibitor_rows is None: return RepairResult("panama.caffeine", True, 1, "Caffeine inhibitor metadata was invalid; nothing was released.") - inhibitor_pids = list(dict.fromkeys(parsed_pids)) + inhibitor_pids = list(dict.fromkeys(int(row[3]) for row in inhibitor_rows)) if len(inhibitor_pids) <= 1: return RepairResult("panama.caffeine", True, 0, "No duplicate Panama Caffeine inhibitors needed release.") @@ -673,15 +800,15 @@ def repair_caffeine(config: DoctorConfig) -> RepairResult: second_exit, second_output = run_repair_command(list_command, config) if second_exit != 0: return RepairResult("panama.caffeine", True, second_exit, "Caffeine inhibitors could not be revalidated; nothing was released.") - second_parsed = parse_caffeine_pids(second_output, uid) - if second_parsed is None or any(pid not in set(second_parsed) for pid in duplicates): + second_rows = parse_caffeine_rows(second_output, uid) + if second_rows is None or second_rows != inhibitor_rows: return RepairResult("panama.caffeine", True, 1, "Caffeine inhibitor metadata changed; nothing was released.") - try: - for pidfd in pidfds: - signal.pidfd_send_signal(pidfd, signal.SIGTERM, None, 0) - except (OSError, ValueError): + signal_outcome = signal_caffeine_pidfds(pidfds) + if signal_outcome == "preflight-failed": return RepairResult("panama.caffeine", True, 1, "A duplicate inhibitor changed before it could be safely released.") + if signal_outcome == "incomplete": + return RepairResult("panama.caffeine", True, 1, "One or more duplicate inhibitors could not be released.") finally: close_pidfds(pidfds) return RepairResult("panama.caffeine", True, 0, "Duplicate Panama Caffeine inhibitors were released. A fresh health check will verify recovery.") diff --git a/tests/quickshell/panama-doctor-contract.sh b/tests/quickshell/panama-doctor-contract.sh index d01b0ea..33c8f0a 100755 --- a/tests/quickshell/panama-doctor-contract.sh +++ b/tests/quickshell/panama-doctor-contract.sh @@ -259,12 +259,14 @@ summary="$(run_doctor --summary)" # Repairs run against a second, disposable Panama root. Every process boundary # records its argv, and every filesystem assertion is confined to this fixture. -repair_root="$fixture/repair-root" +repair_root="$home/.local/share/Panama" repair_log="$runtime_dir/repair.log" -mkdir -p "$repair_root/config/dot" "$repair_root/setup/scripts" +mkdir -p "$repair_root/config/dot" "$repair_root/config/local/share/vicinae/scripts" \ + "$repair_root/setup/scripts" for name in hypr quickshell uwsm vicinae; do mkdir -p "$repair_root/config/dot/$name" done +cp "$repo_dir/setup/scripts/link-vicinae-scripts" "$repair_root/setup/scripts/link-vicinae-scripts" mv "$bin_dir/systemctl" "$bin_dir/systemctl-probe" cat >"$bin_dir/systemctl" <<'EOF' @@ -309,8 +311,14 @@ if [[ "$mode" == disappear && "$count" -ge 2 ]]; then /usr/bin/sleep 0.01 done fi -printf 'Panama %s fixture-user %s systemd-inhibit sleep:idle Caffeine block\n' "$uid" "$preserved" -printf 'Panama %s fixture-user %s systemd-inhibit sleep:idle Caffeine block\n' "$uid" "$preserved" +preserved_comm=systemd-inhibit +if [[ "$mode" == preserve-altered && "$count" -ge 2 ]]; then + preserved_comm=changed-command +fi +printf 'Panama %s fixture-user %s %s sleep:idle Caffeine block\n' "$uid" "$preserved" "$preserved_comm" +if [[ "$mode" != multiplicity || "$count" -lt 2 ]]; then + printf 'Panama %s fixture-user %s systemd-inhibit sleep:idle Caffeine block\n' "$uid" "$preserved" +fi if [[ "$mode" == altered && "$count" -ge 2 ]]; then printf 'Panama %s fixture-user %s systemd-inhibit sleep:idle Other block\n' "$uid" "$duplicate" else @@ -322,28 +330,19 @@ printf 'Panama %s fixture-user 4997 systemd-inhibit sleep:idle Other block\n' "$ printf 'Panama %s fixture-user 4996 systemd-inhibit sleep:idle Caffeine delay\n' "$uid" EOF -cat >"$repair_root/setup/scripts/link-vicinae-scripts" <<'EOF' -#!/usr/bin/bash -set -euo pipefail -printf 'link-vicinae-scripts|%s' "$0" >>"$XDG_RUNTIME_DIR/repair.log" -if (( $# > 0 )); then - printf '|%s' "$@" >>"$XDG_RUNTIME_DIR/repair.log" -fi -printf '\n' >>"$XDG_RUNTIME_DIR/repair.log" -EOF chmod +x "$bin_dir/systemctl" "$bin_dir/panama-action" \ "$bin_dir/systemd-inhibit" "$repair_root/setup/scripts/link-vicinae-scripts" run_repair() { HOME="$home" \ - PATH="$bin_dir" \ + PATH="$bin_dir:/usr/bin" \ XDG_CURRENT_DESKTOP=Hyprland \ PANAMA_DOCTOR_ROOT="$repair_root" \ PANAMA_DOCTOR_HOME="$home" \ PANAMA_DOCTOR_CONFIG_HOME="$config_home" \ PANAMA_DOCTOR_STATE_HOME="$state_home" \ PANAMA_DOCTOR_RUNTIME_DIR="$runtime_dir" \ - PANAMA_DOCTOR_PATH="$bin_dir" \ + PANAMA_DOCTOR_PATH="$bin_dir:/usr/bin" \ PANAMA_DOCTOR_TIMEOUT=0.2 \ /usr/bin/python3 "$doctor" "$@" } @@ -397,13 +396,19 @@ assert_repair_result desktop.vicinae true 5 [[ "$(<"$repair_log")" == 'systemctl|--user|restart|vicinae.service' ]] \ || fail 'failed repair changed the authored argv' -# The Vicinae repair executes only the authored setup helper with no arguments. -: >"$repair_log" +# The real authored Vicinae helper converges the exact child link diagnosed by +# panama-doctor under the isolated HOME. +rm -f "$data_home/vicinae/scripts/panama" +before_vicinae_repair="$(run_repair --json)" +check_status "$before_vicinae_repair" panama.vicinae-commands warning invoke_repair panama.vicinae-commands [[ "$repair_status" == 0 ]] || fail "Vicinae command repair returned $repair_status" assert_repair_result panama.vicinae-commands true 0 -[[ "$(<"$repair_log")" == "link-vicinae-scripts|$repair_root/setup/scripts/link-vicinae-scripts" ]] \ - || fail "Vicinae command repair argv was not exact: $(<"$repair_log")" +after_vicinae_repair="$(run_repair --json)" +check_status "$after_vicinae_repair" panama.vicinae-commands ok +[[ -L "$data_home/vicinae/scripts/panama" \ + && "$(readlink "$data_home/vicinae/scripts/panama")" == "$repair_root/config/local/share/vicinae/scripts" ]] \ + || fail 'Vicinae repair did not install the diagnosed child link' # Runtime-link repair may replace only absent links or symlinks whose lexical # target proves Panama ownership. Every other object remains untouched. @@ -448,7 +453,7 @@ invoke_repair panama.runtime-links [[ -d "$config_home/vicinae" && ! -L "$config_home/vicinae" ]] \ || fail 'runtime-link repair replaced a user-owned directory' -# An injected os.replace failure occurs after the authored temporary symlink is +# An injected exchange failure occurs after the authored candidate symlink is # made; the original link must still be intact. /usr/bin/python3 - "$doctor" "$repair_root" "$fixture/atomic-config" <<'PY' \ || fail 'atomic replacement failure did not preserve the original link' @@ -471,18 +476,66 @@ destination = config_home / "hypr" original = root / "config/dot/quickshell" destination.symlink_to(original, target_is_directory=True) config = module.DoctorConfig(root, config_home.parent, config_home, config_home.parent / "state", config_home.parent / "runtime", "", 0.2) -real_replace = module.os.replace -module.os.replace = lambda source, target: (_ for _ in ()).throw(OSError("fixture replacement failure")) +real_exchange = module.rename_exchange +module.rename_exchange = lambda source, target: (_ for _ in ()).throw(OSError("fixture exchange failure")) try: result = module.repair_runtime_links(config) finally: - module.os.replace = real_replace + module.rename_exchange = real_exchange assert result.exit_code == 1 assert destination.is_symlink() assert os.readlink(destination) == str(original) assert not list(config_home.glob(".panama-link-*")) PY +# A deterministic swap at the ownership/replacement boundary must be detected +# from the exchanged-out object and rolled back, preserving the external link. +/usr/bin/python3 - "$doctor" "$repair_root" "$fixture/toctou-config" "$fixture/external-race-target" <<'PY' \ + || fail 'runtime-link exchange did not restore a boundary-swapped external link' +import importlib.machinery +import importlib.util +import os +import sys +from pathlib import Path + +doctor_path, root_text, config_text, external_text = sys.argv[1:] +loader = importlib.machinery.SourceFileLoader("panama_doctor_toctou", doctor_path) +spec = importlib.util.spec_from_loader(loader.name, loader) +module = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = module +loader.exec_module(module) +root = Path(root_text) +config_home = Path(config_text) +config_home.mkdir(parents=True) +for name, relative in module.RUNTIME_LINK_TARGETS: + (config_home / name).symlink_to(root / relative, target_is_directory=True) +destination = config_home / "uwsm" +destination.unlink() +destination.symlink_to(root / "config/dot/quickshell", target_is_directory=True) +external = Path(external_text) +real_exchange = module.rename_exchange +first = True + +def race_exchange(candidate, target): + global first + if first: + first = False + target.unlink() + target.symlink_to(external, target_is_directory=True) + real_exchange(candidate, target) + +module.rename_exchange = race_exchange +config = module.DoctorConfig(root, config_home.parent, config_home, config_home.parent / "state", config_home.parent / "runtime", "", 0.2) +try: + result = module.repair_runtime_links(config) +finally: + module.rename_exchange = real_exchange +assert result.exit_code == 1 +assert destination.is_symlink() +assert os.readlink(destination) == str(external) +assert not list(config_home.glob(".panama-link-*")) +PY + # Caffeine repair deduplicates rows, pins each distinct duplicate with a # pidfd, revalidates authored metadata, and signals only the duplicate. /usr/bin/sleep 30 & @@ -524,6 +577,86 @@ assert_repair_result panama.caffeine true 1 kill -0 "$altered_preserved" >/dev/null 2>&1 || fail 'metadata refusal signaled the preserved process' kill -0 "$altered_duplicate" >/dev/null 2>&1 || fail 'metadata refusal signaled the candidate process' +# Changing metadata on the preserved row is also a full-identity mismatch, +# even though every duplicate PID remains present. +/usr/bin/sleep 30 & +preserve_changed_keep=$! +child_pids+=("$preserve_changed_keep") +/usr/bin/sleep 30 & +preserve_changed_duplicate=$! +child_pids+=("$preserve_changed_duplicate") +printf '%s %s\n' "$preserve_changed_keep" "$preserve_changed_duplicate" >"$runtime_dir/caffeine-pids" +printf 'preserve-altered\n' >"$runtime_dir/caffeine-mode" +rm -f "$runtime_dir/caffeine-list-count" +invoke_repair panama.caffeine +[[ "$repair_status" == 1 ]] || fail 'preserved-row metadata change was not safely refused' +assert_repair_result panama.caffeine true 1 +kill -0 "$preserve_changed_keep" >/dev/null 2>&1 || fail 'preserved-row mismatch signaled the preserved process' +kill -0 "$preserve_changed_duplicate" >/dev/null 2>&1 || fail 'preserved-row mismatch signaled the duplicate process' + +# A repeated exact row disappearing between lists changes multiplicity and is +# refused before signaling any pinned duplicate. +/usr/bin/sleep 30 & +multiplicity_keep=$! +child_pids+=("$multiplicity_keep") +/usr/bin/sleep 30 & +multiplicity_duplicate=$! +child_pids+=("$multiplicity_duplicate") +printf '%s %s\n' "$multiplicity_keep" "$multiplicity_duplicate" >"$runtime_dir/caffeine-pids" +printf 'multiplicity\n' >"$runtime_dir/caffeine-mode" +rm -f "$runtime_dir/caffeine-list-count" +invoke_repair panama.caffeine +[[ "$repair_status" == 1 ]] || fail 'inhibitor row multiplicity change was not safely refused' +assert_repair_result panama.caffeine true 1 +kill -0 "$multiplicity_keep" >/dev/null 2>&1 || fail 'multiplicity mismatch signaled the preserved process' +kill -0 "$multiplicity_duplicate" >/dev/null 2>&1 || fail 'multiplicity mismatch signaled the duplicate process' + +# The production pidfd release function preflights every candidate before any +# SIGTERM. A refused second preflight leaves both disposable children alive. +/usr/bin/sleep 30 & +preflight_first=$! +child_pids+=("$preflight_first") +/usr/bin/sleep 30 & +preflight_second=$! +child_pids+=("$preflight_second") +/usr/bin/python3 - "$doctor" "$preflight_first" "$preflight_second" <<'PY' \ + || fail 'pidfd preflight failure signaled a disposable duplicate' +import errno +import importlib.machinery +import importlib.util +import os +import signal +import sys + +doctor_path = sys.argv[1] +pids = [int(value) for value in sys.argv[2:]] +loader = importlib.machinery.SourceFileLoader("panama_doctor_preflight", doctor_path) +spec = importlib.util.spec_from_loader(loader.name, loader) +module = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = module +loader.exec_module(module) +pidfds = [os.pidfd_open(pid, 0) for pid in pids] +calls = [] + +def sender(pidfd, sig, siginfo, flags): + calls.append(sig) + if sig == 0 and pidfd == pidfds[1]: + raise PermissionError(errno.EPERM, "fixture preflight refusal") + signal.pidfd_send_signal(pidfd, sig, siginfo, flags) + +try: + outcome = module.signal_caffeine_pidfds(pidfds, sender) +finally: + for pidfd in pidfds: + os.close(pidfd) +assert outcome == "preflight-failed" +assert calls == [0, 0] +for pid in pids: + os.kill(pid, 0) +PY +kill -0 "$preflight_first" >/dev/null 2>&1 || fail 'preflight refusal killed the first duplicate' +kill -0 "$preflight_second" >/dev/null 2>&1 || fail 'preflight refusal killed the second duplicate' + # A candidate that disappears after pidfd acquisition and second-list request # is a safe failure; an unrelated disposable process must remain untouched. /usr/bin/sleep 30 &