- hypr/apps.lua - hypr/autostart.lua - hypr/envs.lua - hypr/hyprland.lua - hypr/hyprsunset.conf - hypr/input.lua - hypr/looknfeel.lua - hypr/omasettings.lua - hypr/xdph.conf - omarchy/branding/about.txt - omarchy/branding/screensaver.txt - omarchy/extensions/omarchy-menu.jsonc - omarchy/hooks/battery-low.d/play-warning-sound.sample - omarchy/hooks/font-set.d/show-font-notification.sample - omarchy/hooks/post-boot.d/weather.sample - omarchy/hooks/post-update.d/install-voxtype.hook - omarchy/hooks/post-update.d/setup-agent.hook - omarchy/hooks/post-update.d/setup-fingerprint.hook - omarchy/hooks/post-update.d/show-update-notification.sample - omarchy/hooks/pre-refresh-pacman.d/add-custom-repo.sample - omarchy/hooks/theme-set.d/show-theme-notification.sample - omarchy/shell.json - omarchy/shell.toml - omarchy/theme.name - omarchy/themes/azure-glow/README.md - omarchy/themes/azure-glow/alacritty.toml - omarchy/themes/azure-glow/btop.theme - omarchy/themes/azure-glow/hyprland.conf - omarchy/themes/azure-glow/hyprlock.conf - omarchy/themes/azure-glow/icons.theme - … 269 more
514 lines
15 KiB
Bash
514 lines
15 KiB
Bash
#!/bin/bash
|
|
|
|
set -euo pipefail
|
|
|
|
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
|
|
|
victim_pid=""
|
|
|
|
cleanup() {
|
|
[[ -n $victim_pid ]] || return 0
|
|
kill -KILL "$victim_pid" 2>/dev/null || true
|
|
wait "$victim_pid" 2>/dev/null || true
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
start_ticks_for() {
|
|
local pid="$1"
|
|
local stat_line stat_tail
|
|
local -a stat_fields
|
|
|
|
stat_line=$(<"/proc/$pid/stat")
|
|
stat_tail="${stat_line##*) }"
|
|
read -r -a stat_fields <<<"$stat_tail"
|
|
printf '%s\n' "${stat_fields[19]}"
|
|
}
|
|
|
|
assert_alive() {
|
|
local pid="$1"
|
|
local description="$2"
|
|
kill -0 "$pid" 2>/dev/null || fail "$description"
|
|
}
|
|
|
|
sleep 30 &
|
|
victim_pid=$!
|
|
victim_start=$(start_ticks_for "$victim_pid")
|
|
|
|
if "$ROOT/process-signal" TERM "$victim_pid" invalid 2>/dev/null; then
|
|
fail "process action rejects malformed start ticks"
|
|
fi
|
|
assert_alive "$victim_pid" "process action rejects malformed start ticks"
|
|
pass "process action rejects malformed start ticks"
|
|
|
|
if "$ROOT/process-signal" TERM "$victim_pid" 0 2>/dev/null; then
|
|
fail "process action rejects zero start ticks"
|
|
fi
|
|
assert_alive "$victim_pid" "process action rejects zero start ticks"
|
|
pass "process action rejects zero start ticks"
|
|
|
|
if "$ROOT/process-signal" TERM "$victim_pid" "$((victim_start + 1))" 2>/dev/null; then
|
|
fail "process action rejects a stale PID identity"
|
|
fi
|
|
assert_alive "$victim_pid" "process action left a process alive after rejecting stale identity"
|
|
pass "process action rejects a stale PID identity"
|
|
|
|
if "$ROOT/process-signal" APP_TERM "$victim_pid" "$((victim_start + 1))" 2>/dev/null; then
|
|
fail "app action rejects a stale selected-process identity"
|
|
fi
|
|
assert_alive "$victim_pid" "app action left a process alive after rejecting stale identity"
|
|
pass "app action rejects a stale selected-process identity"
|
|
|
|
"$ROOT/process-signal" TERM "$victim_pid" "$victim_start" >/dev/null ||
|
|
fail "process action gracefully terminates a matching process"
|
|
wait "$victim_pid" 2>/dev/null || true
|
|
if kill -0 "$victim_pid" 2>/dev/null; then
|
|
fail "process action gracefully terminates a matching process"
|
|
fi
|
|
victim_pid=""
|
|
pass "process action gracefully terminates a matching process"
|
|
|
|
python -c '
|
|
import time
|
|
with open("/proc/self/comm", "w", encoding="ascii") as comm:
|
|
comm.write("quickshell")
|
|
time.sleep(30)
|
|
' &
|
|
victim_pid=$!
|
|
for _ in {1..100}; do
|
|
[[ $(<"/proc/$victim_pid/comm") == "quickshell" ]] && break
|
|
sleep 0.01
|
|
done
|
|
[[ $(<"/proc/$victim_pid/comm") == "quickshell" ]] ||
|
|
fail "protected desktop-session fixture did not become ready"
|
|
victim_start=$(start_ticks_for "$victim_pid")
|
|
protected_result=$(
|
|
"$ROOT/process-signal" TERM "$victim_pid" "$victim_start" 2>&1
|
|
) && fail "process action signaled a protected desktop-session process"
|
|
[[ $protected_result == *"protected desktop-session process"* ]] ||
|
|
fail "process action does not explain protected desktop-session rejection" "$protected_result"
|
|
assert_alive "$victim_pid" "process action killed a protected desktop-session process"
|
|
kill -KILL "$victim_pid"
|
|
wait "$victim_pid" 2>/dev/null || true
|
|
victim_pid=""
|
|
pass "process action protects desktop-session processes in the signal helper"
|
|
|
|
python - "$ROOT/process-signal" <<'PY' ||
|
|
import os
|
|
import select
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import textwrap
|
|
import time
|
|
|
|
helper = sys.argv[1]
|
|
app_name = "activity-app"
|
|
|
|
|
|
def start_ticks(pid):
|
|
raw = open(f"/proc/{pid}/stat", "rb").read()
|
|
return raw[raw.rfind(b") ") + 2:].split()[19].decode()
|
|
|
|
|
|
worker_code = textwrap.dedent(
|
|
f"""
|
|
import time
|
|
with open("/proc/self/comm", "w", encoding="ascii") as comm:
|
|
comm.write({app_name!r})
|
|
time.sleep(30)
|
|
"""
|
|
)
|
|
|
|
app_code = textwrap.dedent(
|
|
f"""
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
with open("/proc/self/comm", "w", encoding="ascii") as comm:
|
|
comm.write({app_name!r})
|
|
|
|
worker = None
|
|
|
|
def stop_app(_signal, _frame):
|
|
if worker is not None and worker.poll() is None:
|
|
worker.terminate()
|
|
# Do not return to the supervisor loop where the worker would be
|
|
# replaced. Immediate exit also keeps this fixture deterministic when
|
|
# SIGTERM interrupts subprocess.wait().
|
|
sys.stdout.flush()
|
|
sys.stderr.flush()
|
|
raise SystemExit(0)
|
|
|
|
signal.signal(signal.SIGTERM, stop_app)
|
|
while True:
|
|
worker = subprocess.Popen(
|
|
[sys.executable, "-c", {worker_code!r}],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
)
|
|
for _ in range(300):
|
|
try:
|
|
worker_name = open(
|
|
f"/proc/{{worker.pid}}/comm", encoding="ascii"
|
|
).read().strip()
|
|
except FileNotFoundError:
|
|
worker_name = ""
|
|
if worker_name == {app_name!r}:
|
|
break
|
|
time.sleep(0.01)
|
|
else:
|
|
raise SystemExit(1)
|
|
print(worker.pid, flush=True)
|
|
worker.wait()
|
|
time.sleep(0.05)
|
|
"""
|
|
)
|
|
|
|
unrelated = subprocess.Popen(
|
|
[sys.executable, "-c", worker_code],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
)
|
|
app = subprocess.Popen(
|
|
[sys.executable, "-c", app_code],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
)
|
|
selected_pid = None
|
|
selected_start = None
|
|
|
|
try:
|
|
ready, _, _ = select.select([app.stdout], [], [], 3)
|
|
if not ready:
|
|
raise SystemExit(1)
|
|
selected_pid = int(app.stdout.readline().strip())
|
|
selected_start = start_ticks(selected_pid)
|
|
result = subprocess.run(
|
|
[helper, "APP_TERM", str(selected_pid), selected_start],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
if result.returncode != 0:
|
|
raise SystemExit(1)
|
|
fields = result.stdout.strip().split("\t")
|
|
if fields != ["signaled", str(app.pid), "APP_TERM", "graceful"]:
|
|
raise SystemExit(1)
|
|
if app.wait(timeout=3) != 0:
|
|
raise SystemExit(1)
|
|
if app.stdout.read().strip():
|
|
# Signaling only the selected child would make the app supervisor
|
|
# replace it and announce another PID here.
|
|
raise SystemExit(1)
|
|
if unrelated.poll() is not None:
|
|
# A same-name process outside the selected direct ancestry is not part
|
|
# of the app action.
|
|
raise SystemExit(1)
|
|
|
|
stubborn_worker_code = textwrap.dedent(
|
|
f"""
|
|
import ctypes
|
|
import os
|
|
import signal
|
|
import time
|
|
|
|
with open("/proc/self/comm", "w", encoding="ascii") as comm:
|
|
comm.write({app_name!r})
|
|
libc = ctypes.CDLL(None, use_errno=True)
|
|
if libc.prctl(1, signal.SIGKILL, 0, 0, 0) != 0:
|
|
raise OSError(ctypes.get_errno(), "prctl")
|
|
if os.getppid() == 1:
|
|
raise SystemExit(1)
|
|
time.sleep(30)
|
|
"""
|
|
)
|
|
stubborn_app_code = textwrap.dedent(
|
|
f"""
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
with open("/proc/self/comm", "w", encoding="ascii") as comm:
|
|
comm.write({app_name!r})
|
|
signal.signal(signal.SIGTERM, signal.SIG_IGN)
|
|
worker = subprocess.Popen(
|
|
[sys.executable, "-c", {stubborn_worker_code!r}],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
)
|
|
for _ in range(300):
|
|
try:
|
|
worker_name = open(
|
|
f"/proc/{{worker.pid}}/comm", encoding="ascii"
|
|
).read().strip()
|
|
except FileNotFoundError:
|
|
worker_name = ""
|
|
if worker_name == {app_name!r}:
|
|
break
|
|
time.sleep(0.01)
|
|
else:
|
|
raise SystemExit(1)
|
|
print(worker.pid, flush=True)
|
|
while True:
|
|
worker.wait()
|
|
worker = subprocess.Popen(
|
|
[sys.executable, "-c", {stubborn_worker_code!r}],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
)
|
|
print(worker.pid, flush=True)
|
|
"""
|
|
)
|
|
app = subprocess.Popen(
|
|
[sys.executable, "-c", stubborn_app_code],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
)
|
|
ready, _, _ = select.select([app.stdout], [], [], 3)
|
|
if not ready:
|
|
raise SystemExit(1)
|
|
selected_pid = int(app.stdout.readline().strip())
|
|
selected_start = start_ticks(selected_pid)
|
|
escalation_started = time.monotonic()
|
|
result = subprocess.run(
|
|
[helper, "APP_TERM", str(selected_pid), selected_start],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
if result.returncode != 0:
|
|
raise SystemExit(1)
|
|
fields = result.stdout.strip().split("\t")
|
|
if fields != ["signaled", str(app.pid), "APP_TERM", "escalated"]:
|
|
raise SystemExit(1)
|
|
if time.monotonic() - escalation_started < 2.8:
|
|
raise SystemExit(1)
|
|
if app.wait(timeout=5) != -signal.SIGKILL:
|
|
raise SystemExit(1)
|
|
if unrelated.poll() is not None:
|
|
raise SystemExit(1)
|
|
finally:
|
|
if app.poll() is None:
|
|
app.terminate()
|
|
try:
|
|
app.wait(timeout=3)
|
|
except subprocess.TimeoutExpired:
|
|
app.kill()
|
|
app.wait()
|
|
if selected_pid is not None:
|
|
try:
|
|
if start_ticks(selected_pid) == selected_start:
|
|
os.kill(selected_pid, signal.SIGKILL)
|
|
except (FileNotFoundError, ProcessLookupError):
|
|
pass
|
|
if unrelated.poll() is None:
|
|
unrelated.kill()
|
|
unrelated.wait()
|
|
PY
|
|
fail "app action gracefully closes or escalates app roots without crossing boundaries"
|
|
pass "app action gracefully closes or escalates app roots without crossing boundaries"
|
|
|
|
python - "$ROOT/process-signal" <<'PY' ||
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
|
|
helper = sys.argv[1]
|
|
victim = subprocess.Popen(["sleep", "30"])
|
|
try:
|
|
raw = open(f"/proc/{victim.pid}/stat", "rb").read()
|
|
start_ticks = raw[raw.rfind(b") ") + 2:].split()[19].decode()
|
|
result = subprocess.run(
|
|
[helper, "KILL", str(victim.pid), start_ticks],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
if result.returncode != 0:
|
|
raise SystemExit(1)
|
|
if victim.wait(timeout=3) != -signal.SIGKILL:
|
|
raise SystemExit(1)
|
|
finally:
|
|
if victim.poll() is None:
|
|
victim.kill()
|
|
victim.wait()
|
|
PY
|
|
fail "process action force kills a matching disposable process"
|
|
pass "process action force kills a matching disposable process"
|
|
|
|
python - "$ROOT/process-signal" <<'PY' ||
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
|
|
helper = sys.argv[1]
|
|
|
|
|
|
def start_ticks(pid):
|
|
raw = open(f"/proc/{pid}/stat", "rb").read()
|
|
return raw[raw.rfind(b") ") + 2:].split()[19].decode()
|
|
|
|
|
|
ancestor_pid = os.getpid()
|
|
ancestor_start = start_ticks(ancestor_pid)
|
|
|
|
# Direct parent: the helper is subprocess.run()'s child.
|
|
result = subprocess.run(
|
|
[helper, "TERM", str(ancestor_pid), ancestor_start],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
if result.returncode == 0 or "ancestor chain" not in result.stderr:
|
|
raise SystemExit(1)
|
|
|
|
# Grandparent: the forked intermediary starts the helper while its parent
|
|
# remains the protected target.
|
|
intermediary_pid = os.fork()
|
|
if intermediary_pid == 0:
|
|
nested = subprocess.run(
|
|
[helper, "TERM", str(ancestor_pid), ancestor_start],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
os._exit(0 if nested.returncode != 0 and "ancestor chain" in nested.stderr else 1)
|
|
|
|
_, status = os.waitpid(intermediary_pid, 0)
|
|
if not os.WIFEXITED(status) or os.WEXITSTATUS(status) != 0:
|
|
raise SystemExit(1)
|
|
PY
|
|
fail "process action protects its helper ancestor chain"
|
|
pass "process action protects its helper ancestor chain"
|
|
|
|
python - "$ROOT/process-signal" <<'PY' ||
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
helper = sys.argv[1]
|
|
zombie_pid = os.fork()
|
|
if zombie_pid == 0:
|
|
os._exit(0)
|
|
|
|
try:
|
|
for _ in range(100):
|
|
status = open(f"/proc/{zombie_pid}/status", encoding="ascii").read()
|
|
if "\nState:\tZ " in "\n" + status:
|
|
break
|
|
time.sleep(0.01)
|
|
else:
|
|
raise SystemExit(1)
|
|
|
|
raw = open(f"/proc/{zombie_pid}/stat", "rb").read()
|
|
start_ticks = raw[raw.rfind(b") ") + 2:].split()[19].decode()
|
|
result = subprocess.run(
|
|
[helper, "TERM", str(zombie_pid), start_ticks],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
if result.returncode == 0 or "zombie or dead" not in result.stderr:
|
|
raise SystemExit(1)
|
|
finally:
|
|
os.waitpid(zombie_pid, 0)
|
|
PY
|
|
fail "process action rejects zombie processes"
|
|
pass "process action rejects zombie processes"
|
|
|
|
foreign_uid_test=$(python - "$ROOT/process-signal" <<'PY'
|
|
import os
|
|
from pathlib import Path
|
|
import subprocess
|
|
import sys
|
|
|
|
helper = sys.argv[1]
|
|
current_uid = os.getuid()
|
|
target = None
|
|
|
|
for status_path in Path("/proc").glob("[0-9]*/status"):
|
|
try:
|
|
status_lines = status_path.read_text(encoding="ascii").splitlines()
|
|
status = {
|
|
line.partition(":")[0]: line.partition(":")[2].strip()
|
|
for line in status_lines
|
|
if ":" in line
|
|
}
|
|
pid = int(status["Pid"])
|
|
user_ids = tuple(int(value) for value in status["Uid"].split())
|
|
raw = (status_path.parent / "stat").read_bytes()
|
|
fields = raw[raw.rfind(b") ") + 2:].split()
|
|
flags = int(fields[6])
|
|
start_ticks = int(fields[19])
|
|
except (FileNotFoundError, KeyError, OSError, ValueError, IndexError):
|
|
continue
|
|
if pid > 1 and current_uid not in user_ids and not flags & 0x00200000:
|
|
target = (pid, start_ticks)
|
|
break
|
|
|
|
if target is None:
|
|
print("skip")
|
|
raise SystemExit(0)
|
|
|
|
result = subprocess.run(
|
|
[helper, "TERM", str(target[0]), str(target[1])],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
if result.returncode == 0 or "another user" not in result.stderr:
|
|
raise SystemExit(1)
|
|
print("checked")
|
|
PY
|
|
) || fail "process action validates ownership from process status"
|
|
|
|
if [[ $foreign_uid_test == "checked" ]]; then
|
|
pass "process action validates ownership from process status"
|
|
else
|
|
pass "no foreign userspace process; skipping ownership rejection test"
|
|
fi
|
|
|
|
kernel_thread_pid=""
|
|
kernel_thread_start=""
|
|
for stat_file in /proc/[0-9]*/stat; do
|
|
[[ -r $stat_file ]] || continue
|
|
stat_line=$(<"$stat_file") || continue
|
|
stat_tail="${stat_line##*) }"
|
|
read -r -a stat_fields <<<"$stat_tail"
|
|
((${#stat_fields[@]} > 19)) || continue
|
|
if ((stat_fields[6] & 0x00200000)); then
|
|
kernel_thread_pid="${stat_file%/stat}"
|
|
kernel_thread_pid="${kernel_thread_pid##*/}"
|
|
kernel_thread_start="${stat_fields[19]}"
|
|
break
|
|
fi
|
|
done
|
|
|
|
if [[ -n $kernel_thread_pid ]]; then
|
|
kernel_result=$("$ROOT/process-signal" TERM "$kernel_thread_pid" "$kernel_thread_start" 2>&1) &&
|
|
fail "process action rejects kernel threads"
|
|
[[ $kernel_result == *"kernel thread"* ]] ||
|
|
fail "process action rejects kernel threads" "$kernel_result"
|
|
pass "process action rejects kernel threads"
|
|
else
|
|
pass "no visible kernel thread; skipping kernel-thread rejection test"
|
|
fi
|
|
|
|
if "$ROOT/process-signal" TERM 1 0 2>/dev/null; then
|
|
fail "process action protects PID 1"
|
|
fi
|
|
pass "process action protects PID 1"
|