Three sources that fail independently, so they are counted and applied separately: a flatpak mirror being down says nothing about whether a kernel security fix is waiting. Blending them into one number would hide exactly the case that matters. Checking costs about nine seconds, which is too long to spend every time a page opens, so the page opens on the last result and says when it was taken. A first visit with nothing cached goes and finds out rather than showing a confident "up to date" it has no basis for. Installing packages takes a snapshot first, named after what is about to happen, so Snapshots shows "before 32 package updates" rather than a timestamp. Best effort: a machine without snapper still updates, because an update that refuses to run when a nicety fails would be worse than one without a restore point. Automatic updates cover applications only, through a Panama-owned user timer running daily with a randomized delay. Packages still ask, and dnf-automatic is reported as absent rather than offered, because installing software is not a settings action. Health gained a check, and that is where the bug was: it first returned status "degraded", which is not in the doctor's vocabulary of ok, warning, error and unconfigured. It was counted as nothing at all while the summary still said healthy -- the same silent no-op this codebase keeps relearning. A contract now asserts every status a check can return is one the doctor counts, and the doctor's own contract knows about the new check rather than failing on its arrival. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
769 lines
33 KiB
Bash
Executable File
769 lines
33 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
|
|
# The doctor is deliberately exercised through its command boundary. The
|
|
# fixture commands include sensitive-looking output so this test proves the
|
|
# report only retains explicitly parsed, non-sensitive observations.
|
|
|
|
set -euo pipefail
|
|
|
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
doctor="$repo_dir/config/dot/quickshell/scripts/panama-doctor"
|
|
theme_helper="$repo_dir/config/dot/quickshell/scripts/panama-theme-apps"
|
|
fixture_root="$repo_dir/tests/quickshell/fixtures/doctor"
|
|
|
|
fail() {
|
|
printf 'panama doctor contract: %s\n' "$1" >&2
|
|
exit 1
|
|
}
|
|
|
|
fixture="$(mktemp -d /tmp/panama-doctor.XXXXXX)"
|
|
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"
|
|
state_home="$home/.local/state"
|
|
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/scripts"
|
|
cp "$fixture_root/bin/"* "$bin_dir/"
|
|
chmod +x "$bin_dir"/*
|
|
|
|
# These are intentionally tiny stand-ins for authored executable probes. The
|
|
# named fixture scripts above cover probes whose output needs branch coverage.
|
|
for tool in hyprctl wl-paste grim tesseract kitty nextcloud rustdesk kdeconnect-cli; do
|
|
cat >"$bin_dir/$tool" <<'EOF'
|
|
#!/usr/bin/bash
|
|
case "${0##*/}" in
|
|
hyprctl) printf 'Hyprland 0.50.0\n' ;;
|
|
esac
|
|
EOF
|
|
chmod +x "$bin_dir/$tool"
|
|
done
|
|
|
|
cat >"$bin_dir/flatpak" <<'EOF'
|
|
#!/usr/bin/bash
|
|
if [[ "${1:-}" == "info" && "${2:-}" == "app.bluebubbles.BlueBubbles" \
|
|
&& "${PANAMA_DOCTOR_FIXTURE_BLUEBUBBLES:-installed}" == "installed" ]]; then
|
|
printf 'BlueBubbles fixture-secret-token\n'
|
|
exit 0
|
|
fi
|
|
exit 1
|
|
EOF
|
|
chmod +x "$bin_dir/flatpak"
|
|
|
|
cat >"$bin_dir/calendar-agenda" <<'EOF'
|
|
#!/usr/bin/bash
|
|
if [[ "${1:-}" != "probe" ]]; then
|
|
exit 2
|
|
fi
|
|
case "${PANAMA_DOCTOR_FIXTURE_CALENDAR:-ready}" in
|
|
ready) printf '{"eds":true,"sourceRegistry":true,"enabledSources":2,"event":"fixture clipboard body"}\n' ;;
|
|
malformed) printf 'calendar AA:BB:CC:DD:EE:FF\n' ;;
|
|
timeout) /usr/bin/sleep 2; printf '{"enabledSources":2}\n' ;;
|
|
*) printf '{"eds":true,"sourceRegistry":true,"enabledSources":0}\n' ;;
|
|
esac
|
|
EOF
|
|
chmod +x "$bin_dir/calendar-agenda"
|
|
|
|
cat >"$bin_dir/panama-brightness" <<'EOF'
|
|
#!/usr/bin/bash
|
|
case "${PANAMA_DOCTOR_FIXTURE_BRIGHTNESS:-ready}" in
|
|
ready) printf '{"displays":[{"connector":"AA:BB:CC:DD:EE:FF"}],"error":""}\n' ;;
|
|
denied) printf '{"displays":[],"error":"fixture-secret-token"}\n' ;;
|
|
malformed) printf 'fixture clipboard body\n' ;;
|
|
esac
|
|
EOF
|
|
chmod +x "$bin_dir/panama-brightness"
|
|
|
|
mkdir -p "$config_home/autostart"
|
|
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/panama"
|
|
mkdir -p "$state_home/panama"
|
|
printf '%s\n' 'background { path = /fixture-secret-wallpaper.jpg }' \
|
|
>"$state_home/panama/hyprlock.conf"
|
|
printf '%s\n' '{"generated":true,"path":"/fixture-secret-wallpaper.jpg","fallback":false,"error":""}' \
|
|
>"$state_home/panama/hyprlock-status.json"
|
|
|
|
run_doctor() {
|
|
HOME="$home" \
|
|
PATH="$bin_dir" \
|
|
XDG_CURRENT_DESKTOP=Hyprland \
|
|
PANAMA_HOME_ASSISTANT_URL='https://fixture.invalid' \
|
|
PANAMA_HOME_ASSISTANT_TOKEN='fixture-secret-token' \
|
|
PANAMA_DOCTOR_ROOT="$repo_dir" \
|
|
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_SCRIPTS_DIR="$bin_dir" \
|
|
PANAMA_DOCTOR_TIMEOUT="${PANAMA_DOCTOR_TIMEOUT:-0.2}" \
|
|
/usr/bin/python3 "$doctor" "$@"
|
|
}
|
|
|
|
expected_order=$'desktop.hyprland\ndesktop.quickshell\ndesktop.notifications\ndesktop.portals\ndesktop.hyprpaper\ndesktop.hypridle\ndesktop.hyprlock\ndesktop.vicinae\ninput.pipewire\ninput.clipboard\ninput.wallpaper\ninput.capture\ninput.ocr\ninput.brightness\nintegration.nextcloud\nintegration.rustdesk\nintegration.kdeconnect\nintegration.bluebubbles\nintegration.home-assistant\nintegration.calendar\npanama.updates\npanama.runtime-links\npanama.vicinae-commands\npanama.selected-terminal\npanama.selected-launcher\npanama.processes\npanama.caffeine'
|
|
|
|
assert_schema_and_redaction() {
|
|
local snapshot="$1"
|
|
jq -e '.schemaVersion == 1
|
|
and (.generatedAt | type == "string")
|
|
and (.summary.status | IN("healthy", "warning", "error"))
|
|
and (.context.session | IN("hyprland", "other"))
|
|
and (.context.versions | type == "array")
|
|
and ([.checks[].id] | length == 27)
|
|
and ([.checks[].id] | unique | length == 27)
|
|
and ([.checks[].status] | all(IN("ok", "warning", "error", "unconfigured")))' \
|
|
>/dev/null <<<"$snapshot" || fail "invalid schema: $snapshot"
|
|
[[ "$(jq -r '.checks[].id' <<<"$snapshot")" == "$expected_order" ]] \
|
|
|| fail "checks are not in the authored order"
|
|
! grep -Fq 'fixture-secret-token' <<<"$snapshot" \
|
|
|| fail 'report exposed a fixture secret'
|
|
! grep -Fq 'fixture clipboard body' <<<"$snapshot" \
|
|
|| fail 'report exposed clipboard or calendar content'
|
|
! grep -Fq 'AA:BB:CC:DD:EE:FF' <<<"$snapshot" \
|
|
|| fail 'report exposed a device address'
|
|
}
|
|
|
|
check_status() {
|
|
local snapshot="$1" id="$2" expected="$3"
|
|
[[ "$(jq -r --arg id "$id" '.checks[] | select(.id == $id) | .status' <<<"$snapshot")" == "$expected" ]] \
|
|
|| fail "$id did not report $expected: $snapshot"
|
|
}
|
|
|
|
snapshot="$(run_doctor --json)"
|
|
assert_schema_and_redaction "$snapshot"
|
|
check_status "$snapshot" panama.vicinae-commands ok
|
|
check_status "$snapshot" desktop.hyprlock ok
|
|
|
|
printf '%s\n' '{"generated":false,"path":"/fixture-secret-wallpaper.jpg","fallback":true,"error":"fixture-secret-token"}' \
|
|
>"$state_home/panama/hyprlock-status.json"
|
|
# A linked source checkout contains the template; installation renders the
|
|
# ignored machine-local fallback beside it. Model that installed state without
|
|
# writing an untracked file into the worktree under test.
|
|
rm "$config_home/hypr"
|
|
mkdir "$config_home/hypr"
|
|
cp "$repo_dir/config/dot/hypr/hyprlock.conf.template" \
|
|
"$config_home/hypr/hyprlock.conf.template"
|
|
HOME="$home" XDG_CONFIG_HOME="$config_home" "$theme_helper" dark >/dev/null
|
|
fallback_lock="$(run_doctor --json)"
|
|
check_status "$fallback_lock" desktop.hyprlock warning
|
|
assert_schema_and_redaction "$fallback_lock"
|
|
|
|
rm -rf "$config_home/hypr"
|
|
missing_lock="$(run_doctor --json)"
|
|
check_status "$missing_lock" desktop.hyprlock error
|
|
assert_schema_and_redaction "$missing_lock"
|
|
ln -s "$repo_dir/config/dot/hypr" "$config_home/hypr"
|
|
printf '%s\n' '{"generated":true,"path":"/fixture-secret-wallpaper.jpg","fallback":false,"error":""}' \
|
|
>"$state_home/panama/hyprlock-status.json"
|
|
|
|
# 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
|
|
|
|
# Arbitrary parent environment values are not propagated into probes.
|
|
sealed_environment="$(PANAMA_DOCTOR_FIXTURE_PROBE_SECRET=fixture-secret-token run_doctor --json)"
|
|
assert_schema_and_redaction "$sealed_environment"
|
|
check_status "$sealed_environment" desktop.hyprpaper ok
|
|
|
|
# An OS-level launch failure is contained as a check result, never a failed
|
|
# doctor invocation or a partial snapshot.
|
|
chmod 0644 "$bin_dir/systemctl"
|
|
if ! launch_failure="$(run_doctor --json)"; then
|
|
chmod +x "$bin_dir/systemctl"
|
|
fail 'launch failure prevented the doctor from emitting JSON'
|
|
fi
|
|
chmod +x "$bin_dir/systemctl"
|
|
assert_schema_and_redaction "$launch_failure"
|
|
check_status "$launch_failure" desktop.hyprpaper error
|
|
|
|
# A missing required executable is an error rather than a crash.
|
|
mv "$bin_dir/qs" "$bin_dir/qs.off"
|
|
missing_qs="$(run_doctor --json)"
|
|
check_status "$missing_qs" desktop.quickshell error
|
|
mv "$bin_dir/qs.off" "$bin_dir/qs"
|
|
|
|
# Optional integrations stay neutral until the user configures them.
|
|
rm "$config_home/autostart/Nextcloud.desktop"
|
|
unconfigured_nextcloud="$(run_doctor --json)"
|
|
check_status "$unconfigured_nextcloud" integration.nextcloud unconfigured
|
|
touch "$config_home/autostart/Nextcloud.desktop"
|
|
|
|
# A configured integration whose process isn't running is actionable with an
|
|
# authored label, never an application name or command derived from probe
|
|
# output. Nextcloud has no systemd unit behind it, so this is a process
|
|
# check (PANAMA_DOCTOR_FIXTURE_PROCESSES), not a service check.
|
|
stopped_nextcloud="$(PANAMA_DOCTOR_FIXTURE_PROCESSES=nextcloud:missing run_doctor --json)"
|
|
check_status "$stopped_nextcloud" integration.nextcloud warning
|
|
jq -e '.checks[] | select(.id == "integration.nextcloud")
|
|
| .action == {kind:"open", label:"Open Nextcloud", confirm:false}' \
|
|
>/dev/null <<<"$stopped_nextcloud" || fail 'Nextcloud action was not authored'
|
|
|
|
# DDC errors are classified without retaining connectors or bus addresses.
|
|
denied_brightness="$(PANAMA_DOCTOR_FIXTURE_BRIGHTNESS=denied run_doctor --json)"
|
|
check_status "$denied_brightness" input.brightness warning
|
|
jq -e '.checks[] | select(.id == "input.brightness")
|
|
| .action == {kind:"instructions", label:"View setup instructions", confirm:false, target:"ddc-permissions"}' \
|
|
>/dev/null <<<"$denied_brightness" || fail 'DDC instructions were not authored'
|
|
|
|
# A bounded probe timeout becomes a result, never a helper failure.
|
|
timed_calendar="$(PANAMA_DOCTOR_FIXTURE_CALENDAR=timeout PANAMA_DOCTOR_TIMEOUT=0.05 run_doctor --json)"
|
|
check_status "$timed_calendar" integration.calendar warning
|
|
jq -e '.checks[] | select(.id == "integration.calendar")
|
|
| .action == {kind:"open", label:"Open Date & Time", confirm:false, target:"datetime"}' \
|
|
>/dev/null <<<"$timed_calendar" || fail 'calendar action was not authored'
|
|
|
|
# Exact Panama/Caffeine inhibitor rows detect duplicates without exposing PIDs.
|
|
duplicated_caffeine="$(PANAMA_DOCTOR_FIXTURE_CAFFEINE=duplicate run_doctor --json)"
|
|
check_status "$duplicated_caffeine" panama.caffeine warning
|
|
jq -e '.checks[] | select(.id == "panama.caffeine")
|
|
| .action == {kind:"repair", label:"Release duplicate inhibitors", confirm:false}' \
|
|
>/dev/null <<<"$duplicated_caffeine" || fail 'Caffeine repair action was not authored'
|
|
! jq -r '.checks[] | select(.id == "panama.caffeine") | .detail' <<<"$duplicated_caffeine" | grep -Eq '[0-9]{3,}' \
|
|
|| fail 'Caffeine detail exposed inhibitor PIDs'
|
|
|
|
# Process counts use only exact authored names and never expose command lines or PIDs.
|
|
duplicated_processes="$(PANAMA_DOCTOR_FIXTURE_PROCESSES=qs:duplicate run_doctor --json)"
|
|
check_status "$duplicated_processes" panama.processes warning
|
|
! jq -r '.checks[] | select(.id == "panama.processes") | .detail' <<<"$duplicated_processes" | grep -Eq '[0-9]{3,}' \
|
|
|| fail 'process detail exposed a PID'
|
|
|
|
missing_quickshell_process="$(PANAMA_DOCTOR_FIXTURE_PROCESSES=qs:missing run_doctor --json)"
|
|
check_status "$missing_quickshell_process" panama.processes error
|
|
|
|
# Invalid output for a non-Quickshell authored process is not a normal zero
|
|
# count that can be hidden by the running Quickshell process.
|
|
malformed_processes="$(PANAMA_DOCTOR_FIXTURE_PROCESSES=hyprpaper:malformed run_doctor --json)"
|
|
check_status "$malformed_processes" panama.processes warning
|
|
|
|
# Panama/Caffeine-shaped rows that do not satisfy the fixed inhibitor schema
|
|
# are unavailable rather than reported as a healthy no-inhibitor state.
|
|
malformed_caffeine="$(PANAMA_DOCTOR_FIXTURE_CAFFEINE=malformed run_doctor --json)"
|
|
check_status "$malformed_caffeine" panama.caffeine warning
|
|
|
|
# A decoding error raised inside a concurrent probe is converted to a complete
|
|
# snapshot rather than escaping from Future.result().
|
|
if ! invalid_probe="$(PANAMA_DOCTOR_FIXTURE_BUS=invalid-utf8 run_doctor --json)"; then
|
|
fail 'unexpected probe exception prevented the doctor from emitting JSON'
|
|
fi
|
|
assert_schema_and_redaction "$invalid_probe"
|
|
check_status "$invalid_probe" desktop.portals warning
|
|
|
|
# Configured Home Assistant failures route to the exact authored Settings page.
|
|
rm "$config_home/quickshell"
|
|
mkdir -p "$config_home/quickshell/scripts"
|
|
home_assistant_failure="$(run_doctor --json)"
|
|
check_status "$home_assistant_failure" integration.home-assistant warning
|
|
jq -e '.checks[] | select(.id == "integration.home-assistant")
|
|
| .action == {kind:"open", label:"Open Home settings", confirm:false, target:"home-phone"}' \
|
|
>/dev/null <<<"$home_assistant_failure" || fail 'Home Assistant action was not routed to home-phone'
|
|
|
|
# Invalid probe text is contained in its own check and never copied to JSON.
|
|
malformed_calendar="$(PANAMA_DOCTOR_FIXTURE_CALENDAR=malformed run_doctor --json)"
|
|
check_status "$malformed_calendar" integration.calendar warning
|
|
assert_schema_and_redaction "$malformed_calendar"
|
|
|
|
summary="$(run_doctor --summary)"
|
|
[[ "$summary" =~ ^Panama\ system\ health:\ (healthy|warning|error)\ \([0-9]+\ ok,\ [0-9]+\ warnings,\ [0-9]+\ errors,\ [0-9]+\ unconfigured\)$ ]] \
|
|
|| fail "summary is not concise: $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="$home/.local/share/Panama"
|
|
repair_log="$runtime_dir/repair.log"
|
|
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'
|
|
#!/usr/bin/bash
|
|
set -euo pipefail
|
|
if [[ "${1:-}" == "--user" && "${2:-}" == "restart" ]]; then
|
|
printf 'systemctl' >>"$XDG_RUNTIME_DIR/repair.log"
|
|
printf '|%s' "$@" >>"$XDG_RUNTIME_DIR/repair.log"
|
|
printf '\n' >>"$XDG_RUNTIME_DIR/repair.log"
|
|
[[ ! -e "$XDG_RUNTIME_DIR/fail-repair" ]] || exit 5
|
|
exit 0
|
|
fi
|
|
exec "${0%/*}/systemctl-probe" "$@"
|
|
EOF
|
|
|
|
cat >"$bin_dir/panama-action" <<'EOF'
|
|
#!/usr/bin/bash
|
|
set -euo pipefail
|
|
printf 'panama-action' >>"$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)"
|
|
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
|
|
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
|
|
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"
|
|
printf 'Panama %s fixture-user 4996 systemd-inhibit sleep:idle Caffeine delay\n' "$uid"
|
|
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:/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:/usr/bin" \
|
|
PANAMA_DOCTOR_SCRIPTS_DIR="$bin_dir" \
|
|
PANAMA_DOCTOR_TIMEOUT=0.2 \
|
|
/usr/bin/python3 "$doctor" "$@"
|
|
}
|
|
|
|
repair_output=""
|
|
repair_status=0
|
|
invoke_repair() {
|
|
set +e
|
|
repair_output="$(run_repair --repair "$1" --json)"
|
|
repair_status=$?
|
|
set -e
|
|
}
|
|
|
|
assert_repair_result() {
|
|
local id="$1" accepted="$2" exit_code="$3"
|
|
jq -e --arg id "$id" --argjson accepted "$accepted" --argjson exitCode "$exit_code" '
|
|
(keys | sort) == ["accepted", "checkId", "exitCode", "message", "schemaVersion"]
|
|
and .schemaVersion == 1
|
|
and .checkId == $id
|
|
and .accepted == $accepted
|
|
and .exitCode == $exitCode
|
|
and (.message | type == "string" and length > 0)
|
|
' >/dev/null <<<"$repair_output" || fail "invalid repair result for $id: $repair_output"
|
|
}
|
|
|
|
for repair_case in \
|
|
'desktop.hyprpaper|systemctl|--user|restart|hyprpaper.service' \
|
|
'desktop.hypridle|systemctl|--user|restart|hypridle.service' \
|
|
'desktop.vicinae|systemctl|--user|restart|vicinae.service' \
|
|
'desktop.quickshell|panama-action|restart-shell'; do
|
|
IFS='|' read -r repair_id executable arg1 arg2 arg3 <<<"$repair_case"
|
|
: >"$repair_log"
|
|
invoke_repair "$repair_id"
|
|
[[ "$repair_status" == 0 ]] || fail "$repair_id returned $repair_status"
|
|
assert_repair_result "$repair_id" true 0
|
|
expected="$executable|$arg1"
|
|
[[ -z "$arg2" ]] || expected+="|$arg2"
|
|
[[ -z "$arg3" ]] || expected+="|$arg3"
|
|
[[ "$(<"$repair_log")" == "$expected" ]] \
|
|
|| fail "$repair_id argv was not exact: $(<"$repair_log")"
|
|
done
|
|
|
|
# Known process failures still return complete JSON and preserve the command's
|
|
# exit status for the QML state machine.
|
|
touch "$runtime_dir/fail-repair"
|
|
: >"$repair_log"
|
|
invoke_repair desktop.vicinae
|
|
rm "$runtime_dir/fail-repair"
|
|
[[ "$repair_status" == 5 ]] || fail "failed repair returned $repair_status instead of 5"
|
|
assert_repair_result desktop.vicinae true 5
|
|
[[ "$(<"$repair_log")" == 'systemctl|--user|restart|vicinae.service' ]] \
|
|
|| fail 'failed repair changed the authored argv'
|
|
|
|
# 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
|
|
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.
|
|
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 "$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 '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 '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'
|
|
|
|
# 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 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'
|
|
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_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.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 &
|
|
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\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'
|
|
|
|
# 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 &
|
|
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() {
|
|
/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
|
|
: >"$repair_log"
|
|
before_state="$(fixture_state)"
|
|
invoke_repair "$rejected_id"
|
|
[[ "$repair_status" == 2 ]] || fail "$rejected_id returned $repair_status instead of 2"
|
|
assert_repair_result "$rejected_id" false 2
|
|
[[ ! -s "$repair_log" ]] || fail "$rejected_id launched a process"
|
|
[[ "$(fixture_state)" == "$before_state" ]] || fail "$rejected_id mutated the filesystem"
|
|
done
|
|
|
|
printf 'panama doctor contract: PASS\n'
|