Drop the extension, and give the test suite a front door
Phase 6, the last of the fresh-install spec. 159 scripts lose their .sh: 110 contracts, 47 Vicinae commands, 2 compositor contracts. A shebang and the executable bit already select the interpreter. The extension only ever added something that had to stay in sync, and the rename proved the point twice over in the space of an hour. The spec's stated risk was Vicinae's script discovery. One script was renamed and reloaded on its own before the other 46 followed; it came back as scripts:panama.capture and all 47 resolve. What the probe turned up instead is that the extension was never only a filename: Vicinae's command IDs embed it, so every ID changed. Nothing in this repository refers to them, so nothing breaks. The only trace is Vicinae's metadata.json, whose visited map had two Panama entries that are now orphaned -- two commands lost their usage ranking and will earn it back. Worth knowing before anyone renames these again on a machine that has a keybind pointing at one. Rewriting the references by exact filename missed two things it structurally could not see: a name built from a variable, settings-$page.sh, and a glob, -name '*.sh'. Both were in the contract that counts the generated commands, which promptly reported 47 expected and 0 found. The mechanical part of a rename is the part that looks finished. The three subcommands. panama doctor fronts a health check that already existed and already ran at the end of every install but could not be reached from a terminal. panama upgrade re-runs the installer from anywhere. panama test runs the suite, which had no entry point at all -- 121 files that were the main safety net in this repository and were invisible in it. Writing that runner found three tests nothing was running. calendar_agenda_bridge_test, home_assistant_bridge_test and kdeconnect_bridge_test are unittest suites without the executable bit, so no contract invoked them and the first draft of the runner skipped them silently. All three pass, and have passed unobserved for weeks. The runner collects *_test.py as well now, because a runner with a blind spot is worse than no runner for the same reason a dependency checker with one is: it reports PASS. Six worktrees pruned. Each was re-checked rather than trusted to the spec's list, and two needed it: panama-commands is not on feat/panama-commands but on feat/gnome-tweaks-parity, and fix/panama-displays-review reads [ahead 3] -- ahead of its remote, not of main, with every commit patch-equivalent to landed work. roadmap-completion stays; it has five commits that are genuinely unlanded. The branches are left alone: pruning a worktree costs nothing, deleting a branch is a decision. 121 contracts pass. Claude-Session: https://claude.ai/code/session_01NvgBuSWB5sE43yWmg21ozj
This commit is contained in:
Executable
+158
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# The firewall page answers "what can another machine reach?", and that answer
|
||||
# needs both halves at once.
|
||||
#
|
||||
# A port is reachable only when something is LISTENING on a network address AND
|
||||
# the firewall permits it. Either half alone is not exposure -- which is exactly
|
||||
# how a tidy rules list coexists with an open database, as it does on this
|
||||
# machine.
|
||||
#
|
||||
# The rules:
|
||||
#
|
||||
# 1. Exposure is the crossing, not either half. A listener the firewall blocks
|
||||
# is not exposed, and an allowed port nothing listens on is not either.
|
||||
# 2. Ephemeral client sockets are not services. A browser's outbound UDP port
|
||||
# looks identical to a service in `ss`, and listing twenty of them buries
|
||||
# the two rows that matter.
|
||||
# 3. Nothing destructive happens without saying what it cuts off, by name.
|
||||
# 4. The page never states what it has not checked. It said "firewalld is
|
||||
# stopped" for the seconds before its first read returned.
|
||||
# 5. Rich rules are shown and never edited: a syntax is not a setting, but
|
||||
# hiding it would misrepresent the configuration.
|
||||
#
|
||||
# Read-only. It never changes a firewall rule.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
helper="$repo_dir/config/dot/quickshell/scripts/panama-firewall"
|
||||
service="$repo_dir/config/dot/quickshell/services/Firewall.qml"
|
||||
page="$repo_dir/config/dot/quickshell/modules/settings/FirewallPage.qml"
|
||||
|
||||
fail() {
|
||||
printf 'firewall contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
for path in "$helper" "$service" "$page"; do
|
||||
[[ -r "$path" ]] || fail "missing $path"
|
||||
done
|
||||
[[ -x "$helper" ]] || fail 'panama-firewall is not executable'
|
||||
|
||||
# ── 1. Exposure is the crossing ─────────────────────────────────────────────
|
||||
grep -q 'def listeners' "$helper" || fail 'nothing enumerates what is listening'
|
||||
grep -q 'def allowed_ports' "$helper" || fail 'nothing enumerates what the firewall permits'
|
||||
# Checked from the DATA, not from the source. An earlier version grepped for
|
||||
# the guard line and passed with it deleted, because the same words appear on an
|
||||
# unrelated line a few lines below -- so the check was matching itself into a
|
||||
# false pass while every listener was being reported as exposed.
|
||||
|
||||
# The crossing itself, against a recorded firewall. It cannot be tested against
|
||||
# this machine: its zone permits every port above 1024, so "listening" and
|
||||
# "listening AND permitted" produce identical answers, and the blocked case
|
||||
# needs a listener below port 1024, which needs root to create.
|
||||
work="$(mktemp -d /tmp/panama-firewall.XXXXXX)"
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
cat >"$work/fixture.json" <<'FIXTURE'
|
||||
{
|
||||
"zones": [{"name": "test", "interfaces": ["eth0"], "services": ["ssh"],
|
||||
"ports": ["8000-8999/tcp"], "richRules": [], "target": "default"}],
|
||||
"servicePorts": {"ssh": ["22/tcp"]},
|
||||
"listeners": [
|
||||
{"port": 22, "protocol": "tcp", "process": "sshd"},
|
||||
{"port": 8080, "protocol": "tcp", "process": "webserver"},
|
||||
{"port": 5432, "protocol": "tcp", "process": "postgres"},
|
||||
{"port": 631, "protocol": "tcp", "process": "cupsd"}
|
||||
]
|
||||
}
|
||||
FIXTURE
|
||||
|
||||
crossed="$(PANAMA_FIREWALL_FIXTURE="$work/fixture.json" "$helper" snapshot 2>/dev/null)" \
|
||||
|| fail 'the recorded firewall could not be read'
|
||||
reachable="$(jq -r '[.exposed[].port] | sort | join(",")' <<<"$crossed")"
|
||||
|
||||
# 22 is allowed by the ssh service; 8080 falls in the open range. 5432 and 631
|
||||
# are listening and NOT permitted, so they are not exposure.
|
||||
[[ "$reachable" == "22,8080" ]] \
|
||||
|| fail "the crossing is wrong: reachable ports were [$reachable], expected [22,8080] -- 5432 and 631 are listening but not permitted"
|
||||
|
||||
jq -e '[.exposed[] | select(.port == 22) | .allowedBy] | .[0] == "the ssh service"' <<<"$crossed" >/dev/null \
|
||||
|| fail 'a port allowed by a named service is not attributed to that service'
|
||||
jq -e '[.exposed[] | select(.port == 8080) | .allowedBy] | .[0] == "the open port range"' <<<"$crossed" >/dev/null \
|
||||
|| fail 'a port allowed by a range is not attributed to the range'
|
||||
|
||||
# ── 2. Ephemeral sockets are excluded ───────────────────────────────────────
|
||||
grep -q 'EPHEMERAL_FLOOR' "$helper" \
|
||||
|| fail 'ephemeral client sockets are not distinguished from services'
|
||||
|
||||
# ── 3. Destructive actions name their consequences ──────────────────────────
|
||||
page_code="$(grep -vE '^\s*//' "$page")"
|
||||
grep -q 'rangeDependents' "$service" \
|
||||
|| fail 'nothing computes what closing the port range would cut off'
|
||||
grep -q 'Closing this cuts off' <<<"$page_code" \
|
||||
|| fail 'closing the port range does not say what it cuts off'
|
||||
grep -q 'confirmingRange' <<<"$page_code" \
|
||||
|| fail 'the port range can be closed without confirming'
|
||||
grep -q 'confirmingRemoval' <<<"$page_code" \
|
||||
|| fail 'a service can be removed without confirming'
|
||||
# Removing ssh while someone is connected over it ends their session.
|
||||
grep -q 'sshSessions' "$service" || fail 'the service does not know about live SSH sessions'
|
||||
grep -q 'connected over SSH right now' <<<"$page_code" \
|
||||
|| fail 'removing ssh does not warn when someone is connected over it'
|
||||
|
||||
# ── 4. The page does not answer before it has looked ────────────────────────
|
||||
grep -q 'Firewall.scanned' <<<"$page_code" \
|
||||
|| fail 'the page reports firewall state before its first read has returned'
|
||||
grep -qE 'Checking' <<<"$page_code" \
|
||||
|| fail 'there is no state for "not read yet", so it must be claiming one of the answers'
|
||||
|
||||
# ── 5. Rich rules are shown, not edited ─────────────────────────────────────
|
||||
grep -q 'richRules' "$helper" || fail 'rich rules are not read, so the page would hide them'
|
||||
grep -q 'richRules' <<<"$page_code" || fail 'rich rules are not shown'
|
||||
grep -qiE 'addRichRule|removeRichRule|--add-rich-rule' "$helper" "$page" \
|
||||
&& fail 'the page edits rich rules, which are a syntax rather than a setting'
|
||||
|
||||
command -v jq >/dev/null 2>&1 || { printf 'firewall contract: SKIP (no jq)\n'; exit 0; }
|
||||
|
||||
state="$("$helper" snapshot 2>/dev/null)" || fail 'snapshot failed'
|
||||
jq -e '(.exposed | type == "array") and (.zones | type == "array")' <<<"$state" >/dev/null \
|
||||
|| fail 'the snapshot is missing exposure or zones'
|
||||
|
||||
if [[ "$(jq -r '.available' <<<"$state")" == "true" ]]; then
|
||||
# Everything reported as exposed must name a rule THIS ZONE ACTUALLY HAS.
|
||||
# A permissive stand-in like "assumed" satisfies "non-empty" while meaning
|
||||
# the crossing was never performed, so the reason is matched against the
|
||||
# zone's real services and port ranges.
|
||||
allowed_reasons="$(jq -r '
|
||||
(.zones[0].services // [] | map("the \(.) service"))
|
||||
+ (if ((.zones[0].ports // []) | length) > 0 then ["the open port range"] else [] end)
|
||||
| .[]' <<<"$state" | sort -u)"
|
||||
[[ -n "$allowed_reasons" ]] || fail 'the zone reports no services and no ports, so nothing could be permitted'
|
||||
while read -r reason; do
|
||||
[[ -n "$reason" ]] || continue
|
||||
grep -qxF "$reason" <<<"$allowed_reasons" \
|
||||
|| fail "something is reported as reachable via \"$reason\", which is not a rule this zone has"
|
||||
done < <(jq -r '.exposed[].allowedBy' <<<"$state" | sort -u)
|
||||
# And must be a real port.
|
||||
jq -e '[.exposed[] | (.port > 0 and .port < 65536)] | all' <<<"$state" >/dev/null \
|
||||
|| fail 'an exposed entry has no valid port'
|
||||
# Loopback-only listeners are not exposure and must never appear.
|
||||
jq -e '[.exposed[] | select(.name == "loopback")] | length == 0' <<<"$state" >/dev/null \
|
||||
|| fail 'a loopback-only listener is reported as reachable'
|
||||
fi
|
||||
|
||||
# ── Refusals ────────────────────────────────────────────────────────────────
|
||||
refusal() { "$helper" "$@" 2>/dev/null | jq -r '.error // ""'; }
|
||||
for bad in "ssh; rm -rf /" "../escape" "" "UPPER CASE"; do
|
||||
[[ -n "$(refusal add-service "$bad")" ]] || fail "a bad service name was accepted: $bad"
|
||||
done
|
||||
for bad in "22" "22/sctp" "70000/tcp" "abc/tcp"; do
|
||||
[[ -n "$(refusal add-port "$bad")" ]] || fail "a bad port specification was accepted: $bad"
|
||||
done
|
||||
[[ -n "$(refusal set-zone 'eth0; reboot' public)" ]] || fail 'a bad interface name was accepted'
|
||||
[[ -n "$(refusal bogus)" ]] || fail 'an unknown command was accepted'
|
||||
|
||||
printf 'firewall contract: PASS (%s reachable, %s of them data stores)\n' \
|
||||
"$(jq '.exposed | length' <<<"$state")" \
|
||||
"$(jq '.exposedDataStores | length' <<<"$state")"
|
||||
Reference in New Issue
Block a user