From 8bfae70284804ff570793d8ac2ceb346097954c0 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 18 Aug 2026 10:58:48 -0400 Subject: [PATCH] Harden bounded Panama recovery actions --- config/dot/quickshell/scripts/panama-doctor | 150 +++++++++---- config/dot/quickshell/services/Health.qml | 4 + tests/quickshell/health-service-contract.sh | 90 +++++++- tests/quickshell/panama-doctor-contract.sh | 230 +++++++++++++++++--- 4 files changed, 403 insertions(+), 71 deletions(-) diff --git a/config/dot/quickshell/scripts/panama-doctor b/config/dot/quickshell/scripts/panama-doctor index ccbf08f..2e8fa03 100755 --- a/config/dot/quickshell/scripts/panama-doctor +++ b/config/dot/quickshell/scripts/panama-doctor @@ -8,6 +8,8 @@ import argparse import json import os import re +import secrets +import signal import shutil import subprocess import sys @@ -409,8 +411,13 @@ def check_runtime_links(config: DoctorConfig) -> Check: def check_vicinae_commands(config: DoctorConfig) -> Check: source = config.root / "config/local/share/vicinae/scripts" - installed = config.home / ".local/share/vicinae/scripts" - if source.is_dir() and installed.is_symlink() and installed.exists(): + installed = config.home / ".local/share/vicinae/scripts/panama" + try: + linked = source.is_dir() and installed.is_symlink() \ + and installed.resolve(strict=False) == source.resolve(strict=True) + except OSError: + linked = False + if linked: return Check("panama.vicinae-commands", "panama-tools", "Panama commands", "ok", "Panama Vicinae commands are linked.") return Check("panama.vicinae-commands", "panama-tools", "Panama commands", "warning", "Panama Vicinae commands are not linked.", Action("repair", "Repair command link")) @@ -445,20 +452,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.") - uid = str(os.getuid()) - inhibitors = 0 - malformed = False - for line in result.stdout.splitlines(): - parts = line.split() - relevant = len(parts) >= 2 and parts[0] == "Panama" and parts[1] == uid and "Caffeine" in parts - if not relevant: - continue - if len(parts) >= 8 and parts[3].isdecimal() and parts[-2:] == ["Caffeine", "block"]: - inhibitors += 1 - else: - malformed = True - if malformed: + inhibitor_pids = parse_caffeine_pids(result.stdout, str(os.getuid())) + if inhibitor_pids is None: return Check("panama.caffeine", "panama-tools", "Caffeine inhibitor", "warning", "Caffeine inhibitor probe returned an invalid result.") + inhibitors = len(dict.fromkeys(inhibitor_pids)) 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: @@ -535,10 +532,47 @@ def repair_authored_command(check_id: str, config: DoctorConfig) -> RepairResult return RepairResult(check_id, True, exit_code, message) +def lexical_path(path: Path) -> Path: + """Normalize dot segments without following any filesystem symlink.""" + return Path(os.path.abspath(os.fspath(path))) + + +def lexical_link_target(destination: Path) -> Path: + target = Path(os.readlink(destination)) + 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.""" + for _ in range(32): + temporary = 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 + except FileExistsError: + continue + finally: + if created: + try: + temporary.unlink() + except FileNotFoundError: + pass + raise OSError("Could not allocate an authored temporary link") + + def repair_runtime_links(config: DoctorConfig) -> RepairResult: - sources = [(name, config.root / relative_source) for name, relative_source in RUNTIME_LINK_TARGETS] + root = lexical_path(config.root) + sources = [(name, lexical_path(config.root / relative_source)) for name, relative_source in RUNTIME_LINK_TARGETS] if any(not source.is_dir() for _, source in sources): return RepairResult("panama.runtime-links", True, 1, "Tracked Panama link destinations are unavailable.") + if any(not source.is_relative_to(root) for _, source in sources): + return RepairResult("panama.runtime-links", True, 1, "Tracked Panama link destinations are invalid.") + authored_sources = frozenset(source for _, source in sources) try: config.config_home.mkdir(parents=True, exist_ok=True) @@ -551,23 +585,26 @@ def repair_runtime_links(config: DoctorConfig) -> RepairResult: destination = config.config_home / name try: if destination.is_symlink(): - if destination.resolve(strict=False) == source.resolve(strict=True): + current_target = lexical_link_target(destination) + if current_target == source: continue - destination.unlink() - destination.symlink_to(source, target_is_directory=True) + if current_target not in authored_sources: + blocked = True + continue + atomic_symlink_replace(destination, source) elif destination.exists(): # A regular file or directory is user-owned unless proven # otherwise. Report it, but never replace it. blocked = True else: - destination.symlink_to(source, target_is_directory=True) + atomic_symlink_replace(destination, source) except OSError: failed = True if failed: return RepairResult("panama.runtime-links", True, 1, "One or more Panama runtime links could not be recreated.") if blocked: - return RepairResult("panama.runtime-links", True, 1, "A user-owned file or directory is blocking a Panama runtime link.") + return RepairResult("panama.runtime-links", True, 1, "A user-owned runtime path is blocking a Panama link.") return RepairResult("panama.runtime-links", True, 0, "Panama runtime links were recreated. A fresh health check will verify them.") @@ -581,6 +618,32 @@ 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] = [] + for line in output.splitlines(): + parts = line.split() + if len(parts) < 2 or parts[0] != "Panama" or parts[1] != uid: + continue + if len(parts) != 8: + if "Caffeine" in parts: + return None + continue + if parts[6] != "Caffeine" or parts[7] != "block": + continue + if not parts[3].isdecimal(): + return None + inhibitor_pids.append(int(parts[3])) + return inhibitor_pids + + +def close_pidfds(pidfds: list[int]) -> None: + for pidfd in pidfds: + try: + os.close(pidfd) + except OSError: + pass + + def repair_caffeine(config: DoctorConfig) -> RepairResult: list_command = ("systemd-inhibit", "--list", "--no-pager", "--no-legend") list_exit, output = run_repair_command(list_command, config) @@ -588,26 +651,39 @@ def repair_caffeine(config: DoctorConfig) -> RepairResult: return RepairResult("panama.caffeine", True, list_exit, "Caffeine inhibitors could not be inspected.") uid = str(os.getuid()) - inhibitor_pids: list[str] = [] - for line in output.splitlines(): - parts = line.split() - if len(parts) < 2 or parts[0] != "Panama" or parts[1] != uid: - continue - if len(parts) != 8: - return RepairResult("panama.caffeine", True, 1, "Caffeine inhibitor metadata was invalid; nothing was released.") - if parts[6] != "Caffeine" or parts[7] != "block": - continue - if not parts[3].isdecimal(): - return RepairResult("panama.caffeine", True, 1, "Caffeine inhibitor metadata was invalid; nothing was released.") - inhibitor_pids.append(parts[3]) + parsed_pids = parse_caffeine_pids(output, uid) + if parsed_pids is None: + return RepairResult("panama.caffeine", True, 1, "Caffeine inhibitor metadata was invalid; nothing was released.") + inhibitor_pids = list(dict.fromkeys(parsed_pids)) if len(inhibitor_pids) <= 1: return RepairResult("panama.caffeine", True, 0, "No duplicate Panama Caffeine inhibitors needed release.") - for pid in inhibitor_pids[1:]: - exit_code, _ = run_repair_command(("kill", "--", pid), config) - if exit_code != 0: - return RepairResult("panama.caffeine", True, exit_code, "A duplicate Panama Caffeine inhibitor could not be released.") + duplicates = inhibitor_pids[1:] + if not hasattr(os, "pidfd_open") or not hasattr(signal, "pidfd_send_signal"): + return RepairResult("panama.caffeine", True, 1, "Safe Caffeine inhibitor release is unavailable on this system.") + + pidfds: list[int] = [] + try: + try: + pidfds = [os.pidfd_open(pid, 0) for pid in duplicates] + except (OSError, ValueError): + return RepairResult("panama.caffeine", True, 1, "A duplicate inhibitor changed before it could be safely released.") + + 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): + 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): + return RepairResult("panama.caffeine", True, 1, "A duplicate inhibitor changed before it could be safely 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/config/dot/quickshell/services/Health.qml b/config/dot/quickshell/services/Health.qml index 9608dba..d79b450 100644 --- a/config/dot/quickshell/services/Health.qml +++ b/config/dot/quickshell/services/Health.qml @@ -117,6 +117,8 @@ Singleton { } function refresh(): bool { + if (root.postRepairScanPending) + return false; if (scanProcess.running || repairProcess.running) { root.queuedRefresh = true; return false; @@ -239,6 +241,8 @@ Singleton { const check = root.checks.find(candidate => candidate.id === id); if (!check || !check.action || check.action.kind !== "repair") return false; + if (external && check.action.confirm) + return false; root.repairingId = id; root.lastError = ""; diff --git a/tests/quickshell/health-service-contract.sh b/tests/quickshell/health-service-contract.sh index 61f81d2..f19ff73 100755 --- a/tests/quickshell/health-service-contract.sh +++ b/tests/quickshell/health-service-contract.sh @@ -7,10 +7,22 @@ set -euo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -harness="$repo_dir/config/dot/quickshell/health-harness.qml" +source_harness="$repo_dir/config/dot/quickshell/health-harness.qml" service="$repo_dir/config/dot/quickshell/services/Health.qml" shell="$repo_dir/config/dot/quickshell/shell.qml" warning_snapshot='{"schemaVersion":1,"generatedAt":"2026-08-18T00:00:00Z","summary":{"status":"warning","healthy":0,"warnings":2,"errors":0,"unconfigured":0},"context":{"session":"hyprland","versions":[{"id":"quickshell","version":"0.3.0"}]},"checks":[{"id":"integration.calendar","group":"integrations","title":"Calendar","status":"warning","detail":"Calendar probe timed out.","action":{"kind":"open","label":"Open Date & Time","confirm":false,"target":"datetime"}},{"id":"panama.caffeine","group":"panama-tools","title":"Caffeine","status":"warning","detail":"Duplicate inhibitors are active.","action":{"kind":"repair","label":"Release duplicate inhibitors","confirm":false}}]}' +confirm_snapshot="$(jq -c ' + .summary.status = "error" + | .summary.errors = 1 + | .checks += [{ + id: "desktop.quickshell", + group: "desktop-foundation", + title: "Quickshell", + status: "error", + detail: "Panama shell needs to restart.", + action: {kind: "repair", label: "Restart Panama", confirm: true} + }] +' <<<"$warning_snapshot")" projection_snapshot="$(jq -c ' .fixtureSecret = "fixture-secret" | .summary.fixtureSecret = "fixture-secret" @@ -33,7 +45,7 @@ fail() { } [[ -f "$service" ]] || fail 'Health.qml is missing' -[[ -f "$harness" ]] || fail 'health harness is missing' +[[ -f "$source_harness" ]] || fail 'health harness is missing' [[ -f "$shell" ]] || fail 'shell.qml is missing' # shell.qml is not started here: it is the active desktop shell. Keep this @@ -72,6 +84,33 @@ if keys != ["summary", "busy", "generation", "acceptedGeneration", "checks"]: PY fixture_dir="$(mktemp -d /tmp/panama-health.XXXXXX)" +config_path="$fixture_dir/quickshell" +cp -a "$repo_dir/config/dot/quickshell" "$config_path" +harness="$config_path/health-harness.qml" +python3 - "$harness" <<'PY' +import sys + +path = sys.argv[1] +source = open(path, encoding="utf-8").read() +needle = ' function repair(id: string): bool { return Health.repair(id, false); }\n' +replacement = needle + ''' function externalRepair(id: string): bool { return Health.repair(id, true); } + function pendingRefreshRace(): string { + const before = Health.generation; + Health.finishRepair(0, "panama.caffeine", false, JSON.stringify({ + schemaVersion: 1, + checkId: "panama.caffeine", + accepted: true, + exitCode: 0, + message: "Fixture repair completed." + })); + const accepted = Health.refresh(); + return JSON.stringify({ accepted: accepted, before: before }); + } +''' +if needle not in source: + raise SystemExit("health harness repair seam is missing") +open(path, "w", encoding="utf-8").write(source.replace(needle, replacement)) +PY helper="$fixture_dir/panama-doctor" copy_bin="$fixture_dir/bin" copy_file="$fixture_dir/copied-report.json" @@ -96,6 +135,11 @@ printf '%s\n' \ ' *) printf "not-json\\n"; exit 0 ;;' \ ' esac' \ 'fi' \ + 'if [[ "$1" == "--repair" && "$2" == "desktop.quickshell" && "$3" == "--json" ]]; then' \ + ' sleep 0.25' \ + ' printf "{\"schemaVersion\":1,\"checkId\":\"desktop.quickshell\",\"accepted\":true,\"exitCode\":0,\"message\":\"Panama shell restart was requested.\"}\\n"' \ + ' exit 0' \ + 'fi' \ 'exit 2' >"$helper" chmod +x "$helper" mkdir -p "$copy_bin" @@ -243,12 +287,52 @@ jq -e '.lastRepair.checkId == "panama.caffeine" and .lastRepair.accepted == fals --argjson before "$mismatch_generation" >/dev/null <<<"$state" \ || fail "mismatched repair JSON escaped containment: $state" +# A refresh arriving after repair settlement but before the deferred mandatory +# scan is coalesced into that scan instead of starting an extra generation. +pending_race="$(run ipc call health-test pendingRefreshRace)" +jq -e '.accepted == false' >/dev/null <<<"$pending_race" \ + || fail "refresh escaped the post-repair pending window: $pending_race" +pending_generation="$(jq -r .before <<<"$pending_race")" +for _ in $(seq 1 120); do + state="$(run ipc call health-test status)" + jq -e '.busy == false and .generation == ($before + 1) and .queuedRefresh == false' \ + --argjson before "$pending_generation" >/dev/null <<<"$state" && break + sleep 0.1 +done +jq -e '.busy == false and .generation == ($before + 1) and .queuedRefresh == false' \ + --argjson before "$pending_generation" >/dev/null <<<"$state" \ + || fail "pending-window refresh created duplicate scans: $state" + +# External IPC cannot bypass an authored confirmation. The same current row is +# still repairable through Settings' external=false path after UI confirmation. +confirm_generation="$(jq -r .generation <<<"$state")" +[[ "$(run ipc call health-test accept "$confirm_snapshot" "$confirm_generation")" == "true" ]] \ + || fail 'confirmation fixture was rejected' +before_repair_lines="$(wc -l <"$repair_log")" +[[ "$(run ipc call health-test externalRepair desktop.quickshell)" == "false" ]] \ + || fail 'external repair bypassed confirmation' +[[ "$(wc -l <"$repair_log")" == "$before_repair_lines" ]] \ + || fail 'external confirmation rejection started a process' +[[ "$(run ipc call health-test repair desktop.quickshell)" == "true" ]] \ + || fail 'confirmed Settings repair was refused' +for _ in $(seq 1 120); do + state="$(run ipc call health-test status)" + jq -e '.busy == false and .generation == ($before + 1)' \ + --argjson before "$confirm_generation" >/dev/null <<<"$state" && break + sleep 0.1 +done +jq -e '.lastRepair == {schemaVersion:1, checkId:"desktop.quickshell", accepted:true, exitCode:0, message:"Panama shell restart was requested."} + and .generation == ($before + 1)' --argjson before "$confirm_generation" \ + >/dev/null <<<"$state" || fail "confirmed Settings repair did not complete safely: $state" +[[ "$(grep -Fc -- '--repair desktop.quickshell --json' "$repair_log")" == 1 ]] \ + || fail 'confirmed Settings repair did not start exactly one repair process' + [[ "$(run ipc call health-test repair unknown.check)" == "false" ]] \ || fail 'unknown check started a repair' [[ "$(run ipc call health-test repair integration.calendar)" == "false" ]] \ || fail 'non-repairable check started a repair' state="$(run ipc call health-test status)" -jq -e '.repairingId == "" and .generation == ($before + 1)' --argjson before "$mismatch_generation" \ +jq -e '.repairingId == "" and .generation == ($before + 1)' --argjson before "$confirm_generation" \ >/dev/null <<<"$state" || fail "rejected repair altered process state: $state" python3 - "$service" <<'PY' || fail 'external repair failure notification is not bounded' diff --git a/tests/quickshell/panama-doctor-contract.sh b/tests/quickshell/panama-doctor-contract.sh index 35fedfd..d01b0ea 100755 --- a/tests/quickshell/panama-doctor-contract.sh +++ b/tests/quickshell/panama-doctor-contract.sh @@ -16,7 +16,15 @@ fail() { } fixture="$(mktemp -d /tmp/panama-doctor.XXXXXX)" -trap 'rm -rf "$fixture"' EXIT +child_pids=() +cleanup() { + for pid in "${child_pids[@]}"; do + kill "$pid" >/dev/null 2>&1 || true + wait "$pid" >/dev/null 2>&1 || true + done + rm -rf "$fixture" +} +trap cleanup EXIT home="$fixture/home" config_home="$home/.config" @@ -25,7 +33,7 @@ runtime_dir="$fixture/runtime" bin_dir="$fixture/bin" data_home="$home/.local/share" -mkdir -p "$config_home" "$state_home" "$runtime_dir" "$bin_dir" "$data_home/vicinae" +mkdir -p "$config_home" "$state_home" "$runtime_dir" "$bin_dir" "$data_home/vicinae/scripts" cp "$fixture_root/bin/"* "$bin_dir/" chmod +x "$bin_dir"/* @@ -81,7 +89,7 @@ touch "$config_home/autostart/nextcloud.desktop" for name in hypr quickshell uwsm vicinae; do ln -s "$repo_dir/config/dot/$name" "$config_home/$name" done -ln -s "$repo_dir/config/local/share/vicinae/scripts" "$data_home/vicinae/scripts" +ln -s "$repo_dir/config/local/share/vicinae/scripts" "$data_home/vicinae/scripts/panama" run_doctor() { HOME="$home" \ @@ -130,6 +138,20 @@ check_status() { snapshot="$(run_doctor --json)" assert_schema_and_redaction "$snapshot" +check_status "$snapshot" panama.vicinae-commands ok + +# The diagnostic follows the actual installer contract: the scripts parent is +# a directory and only its Panama child is an authored link. +rm "$data_home/vicinae/scripts/panama" +unlinked_vicinae="$(run_doctor --json)" +check_status "$unlinked_vicinae" panama.vicinae-commands warning +PANAMA_PATH="$repo_dir" VICINAE_DATA_DIR="$data_home/vicinae" HOME="$home" \ + PATH="$bin_dir:/usr/bin" "$repo_dir/setup/scripts/link-vicinae-scripts" +relinked_vicinae="$(run_doctor --json)" +check_status "$relinked_vicinae" panama.vicinae-commands ok +[[ -L "$data_home/vicinae/scripts/panama" \ + && "$(readlink "$data_home/vicinae/scripts/panama")" == "$repo_dir/config/local/share/vicinae/scripts" ]] \ + || fail 'authored Vicinae helper did not create the diagnosed child link' # A healthy systemd-backed service stays healthy. check_status "$snapshot" desktop.hyprpaper ok @@ -266,23 +288,34 @@ printf '|%s' "$@" >>"$XDG_RUNTIME_DIR/repair.log" printf '\n' >>"$XDG_RUNTIME_DIR/repair.log" EOF -cat >"$bin_dir/kill" <<'EOF' -#!/usr/bin/bash -set -euo pipefail -printf 'kill' >>"$XDG_RUNTIME_DIR/repair.log" -printf '|%s' "$@" >>"$XDG_RUNTIME_DIR/repair.log" -printf '\n' >>"$XDG_RUNTIME_DIR/repair.log" -EOF - cat >"$bin_dir/systemd-inhibit" <<'EOF' #!/usr/bin/bash set -euo pipefail printf 'systemd-inhibit' >>"$XDG_RUNTIME_DIR/repair.log" printf '|%s' "$@" >>"$XDG_RUNTIME_DIR/repair.log" printf '\n' >>"$XDG_RUNTIME_DIR/repair.log" +count_file="$XDG_RUNTIME_DIR/caffeine-list-count" +count=0 +[[ ! -f "$count_file" ]] || read -r count <"$count_file" +count=$((count + 1)) +printf '%s\n' "$count" >"$count_file" +read -r preserved duplicate <"$XDG_RUNTIME_DIR/caffeine-pids" uid="$(/usr/bin/id -u)" -printf 'Panama %s fixture-user 4101 systemd-inhibit sleep:idle Caffeine block\n' "$uid" -printf 'Panama %s fixture-user 4102 systemd-inhibit sleep:idle Caffeine block\n' "$uid" +mode="$(<"$XDG_RUNTIME_DIR/caffeine-mode")" +if [[ "$mode" == disappear && "$count" -ge 2 ]]; then + /usr/bin/touch "$XDG_RUNTIME_DIR/release-disappearing-pid" + for _ in $(/usr/bin/seq 1 100); do + [[ ! -e "/proc/$duplicate" ]] && break + /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" +if [[ "$mode" == altered && "$count" -ge 2 ]]; then + printf 'Panama %s fixture-user %s systemd-inhibit sleep:idle Other block\n' "$uid" "$duplicate" +else + printf 'Panama %s fixture-user %s systemd-inhibit sleep:idle Caffeine block\n' "$uid" "$duplicate" +fi printf 'Other %s fixture-user 4999 systemd-inhibit sleep:idle Caffeine block\n' "$uid" printf 'Panama 99999 fixture-user 4998 systemd-inhibit sleep:idle Caffeine block\n' printf 'Panama %s fixture-user 4997 systemd-inhibit sleep:idle Other block\n' "$uid" @@ -298,7 +331,7 @@ if (( $# > 0 )); then fi printf '\n' >>"$XDG_RUNTIME_DIR/repair.log" EOF -chmod +x "$bin_dir/systemctl" "$bin_dir/panama-action" "$bin_dir/kill" \ +chmod +x "$bin_dir/systemctl" "$bin_dir/panama-action" \ "$bin_dir/systemd-inhibit" "$repair_root/setup/scripts/link-vicinae-scripts" run_repair() { @@ -372,50 +405,185 @@ 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")" -# Runtime-link repair may replace only the four authored symlink names. Broken -# or absent links are recreated toward authored tracked destinations; regular -# files and directories remain untouched and make the result incomplete. +# Runtime-link repair may replace only absent links or symlinks whose lexical +# target proves Panama ownership. Every other object remains untouched. for name in hypr quickshell uwsm vicinae; do path="$config_home/$name" if [[ -e "$path" || -L "$path" ]]; then mv "$path" "$fixture/pre-repair-$name" fi done -ln -s "$fixture/missing-hypr" "$config_home/hypr" -ln -s "$fixture/missing-quickshell" "$config_home/quickshell" -printf 'user-owned file\n' >"$config_home/uwsm" -mkdir "$config_home/vicinae" +ln -s "$repair_root/config/dot/hypr" "$config_home/hypr" +correct_inode="$(stat -c %i "$config_home/hypr")" +ln -s "$repair_root/config/dot/quickshell" "$config_home/uwsm" +ln -s "$fixture/external-broken-link" "$config_home/vicinae" ln -s "$fixture/untouched" "$config_home/not-panama" : >"$repair_log" invoke_repair panama.runtime-links [[ "$repair_status" == 1 ]] || fail "blocked runtime-link repair returned $repair_status" assert_repair_result panama.runtime-links true 1 [[ -L "$config_home/hypr" && "$(readlink "$config_home/hypr")" == "$repair_root/config/dot/hypr" ]] \ - || fail 'hypr link was not recreated toward its authored destination' + || fail 'correct runtime link changed' +[[ "$(stat -c %i "$config_home/hypr")" == "$correct_inode" ]] \ + || fail 'correct runtime link was replaced instead of left untouched' [[ -L "$config_home/quickshell" && "$(readlink "$config_home/quickshell")" == "$repair_root/config/dot/quickshell" ]] \ - || fail 'quickshell link was not recreated toward its authored destination' -[[ -f "$config_home/uwsm" && "$(<"$config_home/uwsm")" == 'user-owned file' ]] \ - || fail 'runtime-link repair replaced a regular file' -[[ -d "$config_home/vicinae" && ! -L "$config_home/vicinae" ]] \ - || fail 'runtime-link repair replaced a user-owned directory' + || fail 'absent quickshell link was not created' +[[ -L "$config_home/uwsm" && "$(readlink "$config_home/uwsm")" == "$repair_root/config/dot/uwsm" ]] \ + || fail 'provably Panama-owned stale link was not repaired' +[[ -L "$config_home/vicinae" && "$(readlink "$config_home/vicinae")" == "$fixture/external-broken-link" ]] \ + || fail 'external broken symlink was replaced' [[ -L "$config_home/not-panama" && "$(readlink "$config_home/not-panama")" == "$fixture/untouched" ]] \ || fail 'runtime-link repair touched an unauthored link name' [[ ! -s "$repair_log" ]] || fail 'runtime-link repair launched a process' -# Caffeine repair parses exact authored metadata, keeps the first valid lock, -# and releases only later exact matches. +# Regular files and directories also remain untouched. +rm "$config_home/vicinae" +rm "$config_home/uwsm" +printf 'user-owned file\n' >"$config_home/uwsm" +mkdir "$config_home/vicinae" +invoke_repair panama.runtime-links +[[ "$repair_status" == 1 ]] || fail 'file/directory blockers did not make repair incomplete' +[[ -f "$config_home/uwsm" && "$(<"$config_home/uwsm")" == 'user-owned file' ]] \ + || fail 'runtime-link repair replaced a regular file' +[[ -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 +# 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' +import importlib.util +import importlib.machinery +import os +import sys +from pathlib import Path + +doctor_path, root_text, config_text = sys.argv[1:] +loader = importlib.machinery.SourceFileLoader("panama_doctor_contract", 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) +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")) +try: + result = module.repair_runtime_links(config) +finally: + module.os.replace = real_replace +assert result.exit_code == 1 +assert destination.is_symlink() +assert os.readlink(destination) == str(original) +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 & +preserved_pid=$! +child_pids+=("$preserved_pid") +/usr/bin/sleep 30 & +duplicate_pid=$! +child_pids+=("$duplicate_pid") +printf '%s %s\n' "$preserved_pid" "$duplicate_pid" >"$runtime_dir/caffeine-pids" +printf 'dedupe\n' >"$runtime_dir/caffeine-mode" +rm -f "$runtime_dir/caffeine-list-count" : >"$repair_log" invoke_repair panama.caffeine [[ "$repair_status" == 0 ]] || fail "Caffeine repair returned $repair_status" assert_repair_result panama.caffeine true 0 -expected_caffeine=$'systemd-inhibit|--list|--no-pager|--no-legend\nkill|--|4102' +expected_caffeine=$'systemd-inhibit|--list|--no-pager|--no-legend\nsystemd-inhibit|--list|--no-pager|--no-legend' [[ "$(<"$repair_log")" == "$expected_caffeine" ]] \ || fail "Caffeine repair did not preserve/filter exact inhibitors: $(<"$repair_log")" +kill -0 "$preserved_pid" >/dev/null 2>&1 || fail 'repeated inhibitor rows killed the preserved process' +for _ in $(seq 1 40); do + kill -0 "$duplicate_pid" >/dev/null 2>&1 || break + sleep 0.05 +done +! kill -0 "$duplicate_pid" >/dev/null 2>&1 || fail 'distinct duplicate inhibitor was not terminated' + +# Changed second-list metadata invalidates the candidate before any signal. +/usr/bin/sleep 30 & +altered_preserved=$! +child_pids+=("$altered_preserved") +/usr/bin/sleep 30 & +altered_duplicate=$! +child_pids+=("$altered_duplicate") +printf '%s %s\n' "$altered_preserved" "$altered_duplicate" >"$runtime_dir/caffeine-pids" +printf 'altered\n' >"$runtime_dir/caffeine-mode" +rm -f "$runtime_dir/caffeine-list-count" +invoke_repair panama.caffeine +[[ "$repair_status" == 1 ]] || fail 'altered inhibitor metadata was not safely refused' +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' + +# 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 & +unrelated_pid=$! +child_pids+=("$unrelated_pid") +( + /usr/bin/sleep 30 & + disappearing_pid=$! + trap 'kill "$disappearing_pid" >/dev/null 2>&1 || true; wait "$disappearing_pid" >/dev/null 2>&1 || true' EXIT + printf '%s\n' "$disappearing_pid" >"$runtime_dir/disappearing-pid" + while [[ ! -e "$runtime_dir/release-disappearing-pid" ]]; do + /usr/bin/sleep 0.01 + done + kill "$disappearing_pid" + wait "$disappearing_pid" >/dev/null 2>&1 || true + trap - EXIT +) & +disappearance_controller=$! +child_pids+=("$disappearance_controller") +for _ in $(seq 1 100); do + [[ -s "$runtime_dir/disappearing-pid" ]] && break + sleep 0.01 +done +[[ -s "$runtime_dir/disappearing-pid" ]] || fail 'disappearing PID fixture did not start' +disappearing_pid="$(<"$runtime_dir/disappearing-pid")" +printf '%s %s\n' "$altered_preserved" "$disappearing_pid" >"$runtime_dir/caffeine-pids" +printf 'disappear\n' >"$runtime_dir/caffeine-mode" +rm -f "$runtime_dir/caffeine-list-count" +invoke_repair panama.caffeine +[[ "$repair_status" == 1 ]] || fail 'disappeared inhibitor PID was not safely refused' +assert_repair_result panama.caffeine true 1 +wait "$disappearance_controller" +kill -0 "$unrelated_pid" >/dev/null 2>&1 || fail 'PID disappearance signaled an unrelated process' # Rejected IDs are complete JSON, exit 2, and cause neither a process launch # nor a filesystem mutation. fixture_state() { - find "$config_home" -mindepth 1 -printf '%P|%y|%l\n' | sort | sha256sum | awk '{print $1}' + /usr/bin/python3 - "$fixture" <<'PY' +import hashlib +import os +import stat +import sys +from pathlib import Path + +root = Path(sys.argv[1]) +digest = hashlib.sha256() +for path in sorted(root.rglob("*"), key=lambda item: os.fsencode(str(item.relative_to(root)))): + relative = os.fsencode(str(path.relative_to(root))) + metadata = path.lstat() + digest.update(relative + b"\0" + oct(stat.S_IMODE(metadata.st_mode)).encode() + b"\0") + if path.is_symlink(): + digest.update(b"link\0" + os.fsencode(os.readlink(path)) + b"\0") + elif path.is_file(): + digest.update(b"file\0" + hashlib.sha256(path.read_bytes()).digest()) + elif path.is_dir(): + digest.update(b"dir\0") + else: + digest.update(b"other\0") +print(digest.hexdigest()) +PY } for rejected_id in unknown.check integration.home-assistant input.brightness \ desktop.notifications ../../escape 'desktop.vicinae;touch injected'; do