#!/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")"
