mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-06 23:12:22 +02:00
A recorded pid is a claim, not a handle. The containment grant store, the background-job store and the Cookbook task list all outlive the process that wrote them — deliberately, so a restart keeps a job and its result — and the kernel reuses pids. Any teardown driven off one of those records can therefore land on a process we never started. ODY-86 was exactly this, and the Cookbook survivor sweep still terminated any process whose full command line matched a tracked one, which is the same mistake spelled differently. Identity is (pid, start token). The token comes from /proc/<pid>/stat on Linux, ps -o lstart= on macOS and the BSDs, and GetProcessTimes on Windows; the kernel will not hand a pid to a process that started earlier, so comparing the token recorded at launch against the token read now answers "is this still ours" without a handle or a supervisor. verify() returns owned, gone, foreign or unverifiable, and only owned permits a signal. Keeping "unverifiable" out of the other two is the point. Process inspection has broken off Linux four times here — ODY-70, -86, -94, -99 — every time because an absent mechanism read as a successful answer. Folding it into "ours" signals strangers; folding it into "gone" abandons live processes. It is a containment failure and every caller treats it as one. Wired into the three places that signal: - containment.release() gates a grant recovered from the durable store, and leaves an in-process teardown alone, where the caller holds the child and no identity question arises. The verdict lands on the record, so "why is this grant still here" is answerable afterwards. - A startup reaper. Nothing read either store before, so a crashed run left every grant permanently active and every job permanently running, and the first thing to touch such a record was a teardown aimed at a reassigned pid. The two stores get opposite treatment: an orphaned grant has no caller left and is torn down, while a detached job is documented to survive a restart and is only corrected, never killed. - The Cookbook sweep takes its ownership from the tmux pane's process tree, captured before the kill destroys the only link between a surviving model server and the session that started it. A process that merely matches the tracked command line is now reported rather than killed: the Cookbook composed that command line, so an identical one is just as likely to be a server the user started by hand. The sweep also runs on hosts with no procfs instead of silently skipping, and says so when it could not look at all.
328 lines
12 KiB
Python
328 lines
12 KiB
Python
"""Process identity: the four verdicts, and that the fourth is never permissive.
|
|
|
|
Split in two. The verdict tests use **real processes**, because the claim under
|
|
test is about the kernel's behaviour — a pid that has been reaped, a pid that was
|
|
never issued, a pid whose start time differs from the one recorded — and a fake
|
|
process table cannot be wrong about that in the same ways. The mechanism tests
|
|
substitute the inspection layer, so both the procfs branch and the ``ps`` branch
|
|
are exercised on whichever kind of host happens to be running them.
|
|
|
|
The invariant worth most here is negative: :data:`process_ownership.OWNED` is the
|
|
only verdict that permits a signal, and nothing — a missing token, an absent
|
|
mechanism, a probe that raised — may produce it by default. Process inspection
|
|
has broken off Linux four times in this tree (ODY-70, -86, -94, -99), every time
|
|
because an absent mechanism read as a successful answer.
|
|
"""
|
|
|
|
import os
|
|
import subprocess
|
|
|
|
import pytest
|
|
|
|
from core import platform_compat
|
|
from src import process_ownership as po
|
|
|
|
|
|
@pytest.fixture
|
|
def sleeper():
|
|
"""A real, short-lived child in its own session. Always reaped."""
|
|
procs = []
|
|
|
|
def _spawn(argv=("sleep", "30")):
|
|
proc = subprocess.Popen(list(argv), start_new_session=True)
|
|
procs.append(proc)
|
|
return proc
|
|
|
|
yield _spawn
|
|
for proc in procs:
|
|
try:
|
|
proc.kill()
|
|
proc.wait(timeout=5)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
# ── Verdicts, against real processes ────────────────────────────────────────
|
|
def test_a_live_process_with_its_own_token_is_owned(sleeper):
|
|
proc = sleeper()
|
|
token = po.start_token(proc.pid)
|
|
|
|
assert token
|
|
assert po.verify(proc.pid, token) == po.OWNED
|
|
|
|
|
|
def test_the_same_pid_with_a_different_token_is_foreign(sleeper):
|
|
"""The whole point: a pid is a slot, and the token says who is in it."""
|
|
proc = sleeper()
|
|
token = po.start_token(proc.pid)
|
|
|
|
assert po.verify(proc.pid, str(token) + "-not-this-one") == po.FOREIGN
|
|
|
|
|
|
def test_a_reaped_process_is_gone(sleeper):
|
|
proc = sleeper()
|
|
token = po.start_token(proc.pid)
|
|
proc.kill()
|
|
proc.wait(timeout=5)
|
|
|
|
assert po.verify(proc.pid, token) == po.GONE
|
|
|
|
|
|
def test_a_pid_that_was_never_issued_is_gone():
|
|
# Above any plausible pid_max, so this cannot collide with a real process.
|
|
assert po.verify(2 ** 30, "token:anything") == po.GONE
|
|
|
|
|
|
def test_a_missing_token_is_unverifiable_and_never_owned(sleeper):
|
|
"""A record that captured no identity cannot acquire one afterwards.
|
|
|
|
This is the pre-upgrade record, and the reason it must not be OWNED is that
|
|
treating "we did not write it down" as "it is ours" is what makes a recycled
|
|
pid lethal.
|
|
"""
|
|
proc = sleeper()
|
|
|
|
assert po.verify(proc.pid, None) == po.UNVERIFIABLE
|
|
assert po.verify(proc.pid, "") == po.UNVERIFIABLE
|
|
assert po.UNVERIFIABLE not in po.SIGNALLABLE
|
|
|
|
|
|
def test_only_owned_permits_a_signal():
|
|
assert po.SIGNALLABLE == frozenset({po.OWNED})
|
|
|
|
|
|
def test_a_falsy_pid_is_gone_rather_than_unverifiable():
|
|
"""Nothing to identify and nothing to signal; the record is just empty."""
|
|
assert po.verify(None, "token:x") == po.GONE
|
|
assert po.verify(0, "token:x") == po.GONE
|
|
|
|
|
|
def test_capture_always_returns_both_fields(sleeper):
|
|
proc = sleeper()
|
|
captured = po.capture(proc.pid)
|
|
|
|
assert set(captured) == {"pid", "start_token"}
|
|
assert captured["pid"] == proc.pid
|
|
assert po.verify(captured["pid"], captured["start_token"]) == po.OWNED
|
|
|
|
|
|
def test_this_process_verifies_as_itself():
|
|
assert po.verify(os.getpid(), po.start_token(os.getpid())) == po.OWNED
|
|
|
|
|
|
# ── An unavailable mechanism is a failure, not a default ────────────────────
|
|
def test_no_inspection_mechanism_yields_unverifiable(monkeypatch, sleeper):
|
|
proc = sleeper()
|
|
token = po.start_token(proc.pid)
|
|
monkeypatch.setattr(po, "inspection_mechanism", lambda: po.MECHANISM_NONE)
|
|
|
|
# Not GONE (which would abandon a live process) and not OWNED (which would
|
|
# license a signal at an unidentified one).
|
|
assert po.verify(proc.pid, token) == po.UNVERIFIABLE
|
|
|
|
|
|
def test_no_mechanism_makes_start_token_raise_rather_than_return_none(monkeypatch):
|
|
"""None means "no such process". A question we could not ask is not that."""
|
|
monkeypatch.setattr(po, "inspection_mechanism", lambda: po.MECHANISM_NONE)
|
|
|
|
with pytest.raises(po.InspectionUnavailable):
|
|
po.start_token(os.getpid())
|
|
|
|
|
|
def test_a_probe_that_raises_is_unverifiable_not_owned(monkeypatch, sleeper):
|
|
proc = sleeper()
|
|
|
|
def _broken(_pid):
|
|
raise po.InspectionUnavailable("deliberately broken probe")
|
|
|
|
monkeypatch.setattr(po, "start_token", _broken)
|
|
|
|
assert po.verify(proc.pid, "token:whatever") == po.UNVERIFIABLE
|
|
|
|
|
|
def test_capture_records_no_token_rather_than_failing(monkeypatch):
|
|
"""A host that cannot identify its children must still be able to launch.
|
|
|
|
The record then reads UNVERIFIABLE forever, which is the honest outcome:
|
|
the launch is allowed, and the later teardown refuses.
|
|
"""
|
|
monkeypatch.setattr(po, "inspection_mechanism", lambda: po.MECHANISM_NONE)
|
|
|
|
captured = po.capture(4242)
|
|
|
|
assert captured == {"pid": 4242, "start_token": None}
|
|
assert po.verify(4242, captured["start_token"]) == po.UNVERIFIABLE
|
|
|
|
|
|
def test_process_table_raises_without_any_mechanism(monkeypatch):
|
|
monkeypatch.setattr(po, "inspection_mechanism", lambda: po.MECHANISM_NONE)
|
|
|
|
with pytest.raises(po.InspectionUnavailable):
|
|
po.process_table()
|
|
|
|
|
|
def test_the_procfs_table_guards_its_own_scan(monkeypatch, tmp_path):
|
|
"""Guarded in the function that scans, not only in its caller.
|
|
|
|
tests/test_procfs_scan_guard.py pins this structurally; this pins the
|
|
behaviour, so calling the branch directly on a procfs-less host raises
|
|
instead of FileNotFoundError.
|
|
"""
|
|
monkeypatch.setattr(platform_compat, "PROC_ROOT", tmp_path / "absent")
|
|
|
|
with pytest.raises(po.InspectionUnavailable):
|
|
po._procfs_process_table()
|
|
|
|
|
|
# ── Mechanism selection ─────────────────────────────────────────────────────
|
|
def test_procfs_is_preferred_where_it_exists(monkeypatch, tmp_path):
|
|
procfs = tmp_path / "proc"
|
|
procfs.mkdir()
|
|
monkeypatch.setattr(platform_compat, "PROC_ROOT", procfs)
|
|
monkeypatch.setattr(po, "PROC_ROOT", procfs)
|
|
monkeypatch.setattr(po, "IS_WINDOWS", False)
|
|
|
|
assert po.inspection_mechanism() == po.MECHANISM_PROCFS
|
|
|
|
|
|
def test_ps_covers_hosts_with_no_procfs(monkeypatch, tmp_path):
|
|
"""macOS and the BSDs. The reason this module is not another /proc scan."""
|
|
monkeypatch.setattr(platform_compat, "PROC_ROOT", tmp_path / "absent")
|
|
monkeypatch.setattr(po, "IS_WINDOWS", False)
|
|
monkeypatch.setattr(po.shutil, "which", lambda name: "/bin/ps" if name == "ps" else None)
|
|
|
|
assert po.inspection_mechanism() == po.MECHANISM_PS
|
|
assert po.inspection_available()
|
|
|
|
|
|
def test_a_host_with_neither_reports_none(monkeypatch, tmp_path):
|
|
monkeypatch.setattr(platform_compat, "PROC_ROOT", tmp_path / "absent")
|
|
monkeypatch.setattr(po, "IS_WINDOWS", False)
|
|
monkeypatch.setattr(po.shutil, "which", lambda _name: None)
|
|
|
|
assert po.inspection_mechanism() == po.MECHANISM_NONE
|
|
assert not po.inspection_available()
|
|
|
|
|
|
def test_the_procfs_token_reads_a_comm_containing_spaces_and_parens(monkeypatch, tmp_path):
|
|
"""``/proc/<pid>/stat`` field 2 is attacker-adjacent: it is the executable name.
|
|
|
|
A process called ``my (weird) prog`` would shift every field after it if the
|
|
parser split on whitespace, which would silently read the wrong number as the
|
|
start time and make every verdict wrong.
|
|
"""
|
|
procfs = tmp_path / "proc"
|
|
(procfs / "77").mkdir(parents=True)
|
|
# "77 (comm) S" are fields 1-3, so the filler starts numbering at 4 and
|
|
# each value equals its own field number.
|
|
fields = " ".join(str(index) for index in range(4, 54))
|
|
(procfs / "77" / "stat").write_text(f"77 (my (weird) prog) S {fields}\n")
|
|
monkeypatch.setattr(platform_compat, "PROC_ROOT", procfs)
|
|
monkeypatch.setattr(po, "PROC_ROOT", procfs)
|
|
monkeypatch.setattr(po, "IS_WINDOWS", False)
|
|
|
|
assert po.start_token(77) == "procfs:22"
|
|
|
|
|
|
# ── The process tree ────────────────────────────────────────────────────────
|
|
def test_descendants_walks_a_real_tree(sleeper):
|
|
"""The grandchild case: a shell that backgrounds work and the work itself."""
|
|
proc = sleeper(("bash", "-c", "sleep 30 & sleep 30"))
|
|
# Wait for the shell to have actually forked, without sleeping on a clock:
|
|
# poll the table until the children appear or the attempts run out.
|
|
found = []
|
|
for _attempt in range(100):
|
|
found = po.descendants([proc.pid])
|
|
if len(found) >= 3:
|
|
break
|
|
|
|
assert proc.pid in found
|
|
assert len(found) >= 3, f"expected the shell and its two children, got {found}"
|
|
|
|
|
|
def test_descendants_includes_the_root_even_with_no_children(sleeper):
|
|
proc = sleeper()
|
|
|
|
assert po.descendants([proc.pid]) == [proc.pid]
|
|
|
|
|
|
def test_descendants_takes_a_single_table_snapshot(monkeypatch):
|
|
"""One table in, one answer out — reparenting cannot hide a process.
|
|
|
|
Walking the tree with a fresh query per level lets a child be reparented
|
|
between queries and drop out of the result, which for a teardown means a
|
|
process nobody signals.
|
|
"""
|
|
rows = {
|
|
10: po.ProcessInfo(pid=10, ppid=1, command="root"),
|
|
11: po.ProcessInfo(pid=11, ppid=10, command="child"),
|
|
12: po.ProcessInfo(pid=12, ppid=11, command="grandchild"),
|
|
13: po.ProcessInfo(pid=13, ppid=1, command="unrelated"),
|
|
}
|
|
|
|
def _explode():
|
|
raise AssertionError("descendants must use the table it was given")
|
|
|
|
monkeypatch.setattr(po, "process_table", _explode)
|
|
|
|
assert po.descendants([10], table=rows) == [10, 11, 12]
|
|
|
|
|
|
def test_descendants_terminates_on_a_parent_cycle():
|
|
"""A table can report a cycle; the walk must not spin on it."""
|
|
rows = {
|
|
20: po.ProcessInfo(pid=20, ppid=21, command="a"),
|
|
21: po.ProcessInfo(pid=21, ppid=20, command="b"),
|
|
}
|
|
|
|
assert sorted(po.descendants([20], table=rows)) == [20, 21]
|
|
|
|
|
|
def test_the_procfs_table_reads_the_parent_pid(monkeypatch, tmp_path):
|
|
"""The procfs branch of the tree walk, exercised on a host without procfs.
|
|
|
|
macOS runs this suite and takes the ``ps`` branch, so without a substituted
|
|
``/proc`` the Linux parse — which is what the deployed image uses — would be
|
|
covered by nothing.
|
|
"""
|
|
procfs = tmp_path / "proc"
|
|
for pid, ppid in ((10, 1), (11, 10)):
|
|
(procfs / str(pid)).mkdir(parents=True)
|
|
(procfs / str(pid) / "cmdline").write_bytes(f"proc-{pid}\0--flag\0".encode())
|
|
filler = " ".join(str(index) for index in range(5, 54))
|
|
(procfs / str(pid) / "stat").write_text(f"{pid} (proc) S {ppid} {filler}\n")
|
|
monkeypatch.setattr(platform_compat, "PROC_ROOT", procfs)
|
|
monkeypatch.setattr(po, "PROC_ROOT", procfs)
|
|
monkeypatch.setattr(po, "IS_WINDOWS", False)
|
|
|
|
table = po.process_table()
|
|
|
|
assert table[11].ppid == 10
|
|
assert table[10].command == "proc-10 --flag"
|
|
assert po.descendants([10], table=table) == [10, 11]
|
|
|
|
|
|
def test_a_procfs_row_with_an_unreadable_stat_keeps_its_command_line(
|
|
monkeypatch, tmp_path
|
|
):
|
|
"""A kernel thread or a pid that exits mid-walk still matters to a
|
|
command-line match; dropping the row entirely would hide it."""
|
|
procfs = tmp_path / "proc"
|
|
(procfs / "12").mkdir(parents=True)
|
|
(procfs / "12" / "cmdline").write_bytes(b"orphan-cmd\0")
|
|
monkeypatch.setattr(platform_compat, "PROC_ROOT", procfs)
|
|
monkeypatch.setattr(po, "PROC_ROOT", procfs)
|
|
monkeypatch.setattr(po, "IS_WINDOWS", False)
|
|
|
|
table = po.process_table()
|
|
|
|
assert table[12].command == "orphan-cmd"
|
|
assert table[12].ppid == 0
|
|
|
|
|
|
def test_command_lines_sees_this_process():
|
|
table = po.command_lines()
|
|
|
|
assert os.getpid() in table
|
|
assert table[os.getpid()]
|