Own the network: details, VPN, enterprise Wi-Fi, and a firewall that can also allow
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -20,12 +20,126 @@ set -euo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
harness="$repo_dir/config/dot/quickshell/connectivity-harness.qml"
|
||||
service="$repo_dir/config/dot/quickshell/services/Connectivity.qml"
|
||||
page="$repo_dir/config/dot/quickshell/modules/settings/ConnectivityPage.qml"
|
||||
|
||||
fail() {
|
||||
printf 'connectivity contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ── Connectivity stays native ────────────────────────────────────────────────
|
||||
#
|
||||
# This service reads NetworkManager and BlueZ through Quickshell's own bindings,
|
||||
# never by shelling out. That is not a style preference: a subprocess per read
|
||||
# turns a property binding into a fork on every repaint, and it loses the change
|
||||
# signals the whole page is built on -- the page would go back to polling and
|
||||
# would go stale between polls.
|
||||
#
|
||||
# When Connections grew a helper (`panama-network`, driven by NetworkTools), the
|
||||
# obvious shortcut was to let this service reach for it too. It must not. Every
|
||||
# nmcli invocation belongs on the far side of that helper; what stays here is
|
||||
# the native state and the wrappers over it.
|
||||
for path in "$service" "$page"; do
|
||||
[[ -r "$path" ]] || fail "missing $path"
|
||||
done
|
||||
service_code="$(grep -vE '^\s*//' "$service")"
|
||||
! grep -qE '\bnmcli\b|\bProcess\b|execDetached|exec\(' <<<"$service_code" \
|
||||
|| fail 'Connectivity.qml shells out; NetworkManager reads here are native, and nmcli belongs in panama-network'
|
||||
|
||||
# The page used to write NetworkManager state straight from a switch
|
||||
# (`Networking.wifiEnabled = value`, `adapter.enabled = value`), which put two
|
||||
# owners on one piece of state and left the service unable to react to its own
|
||||
# change. The wrappers exist so the page has exactly one way in.
|
||||
grep -q 'function setWifiEnabled' <<<"$service_code" \
|
||||
|| fail 'the service has no Wi-Fi wrapper, so the page has to write NetworkManager itself'
|
||||
grep -q 'function setBluetoothEnabled' <<<"$service_code" \
|
||||
|| fail 'the service has no Bluetooth wrapper, so the page has to write the adapter itself'
|
||||
|
||||
page_code="$(grep -vE '^\s*//' "$page")"
|
||||
grep -q 'Connectivity.setWifiEnabled' <<<"$page_code" \
|
||||
|| fail 'the Wi-Fi switch does not go through the service'
|
||||
grep -q 'Connectivity.setBluetoothEnabled' <<<"$page_code" \
|
||||
|| fail 'the Bluetooth switch does not go through the service'
|
||||
! grep -qE 'Networking\.wifiEnabled\s*=' <<<"$page_code" \
|
||||
|| fail 'the page still writes Networking.wifiEnabled directly, bypassing the service'
|
||||
! grep -qE 'adapter\.enabled\s*=' <<<"$page_code" \
|
||||
|| fail 'the page still writes the Bluetooth adapter directly, bypassing the service'
|
||||
|
||||
# The card that sent people to GNOME for Wi-Fi and networking is gone, and with
|
||||
# it the claim that Fedora owns this page. gnome-handoff-contract pins the rule;
|
||||
# this pins the specific rows, because they are what the card was made of.
|
||||
! grep -q 'Owned by Fedora' <<<"$page_code" \
|
||||
|| fail 'the "Owned by Fedora" card is back on a page that now manages the network itself'
|
||||
! grep -qE 'openGnomePanel\("(wifi|network)"' <<<"$page_code" \
|
||||
|| fail 'Connections still hands the user to GNOME for something it now does'
|
||||
|
||||
# ── Every enum member the service names must exist ───────────────────────────
|
||||
#
|
||||
# This is the first bug in this file's header, generalized. `NetworkDeviceType
|
||||
# .Wifi` does not exist, so the lookup returned null and the page said "No Wi-Fi
|
||||
# adapter" on a machine whose Wi-Fi was connected. The same thing happened again
|
||||
# in securityLabel: `WifiSecurityType.Wep`, `.Wpa`, `.Wpa2`, `.Wpa3` and
|
||||
# `.Enterprise` are not members either -- the real ones are WpaPsk, Wpa2Psk,
|
||||
# Sae, StaticWep, WpaEap and so on -- so every switch arm compared against
|
||||
# `undefined`, nothing ever matched, and every secured network was labeled with
|
||||
# the fallthrough. Plausible names, no error, wrong answer.
|
||||
#
|
||||
# QML resolves an unknown enum member to undefined and says nothing, so no test
|
||||
# that runs the code can notice. This reads the member list out of the installed
|
||||
# Quickshell's own type description, so it stays true across upgrades rather
|
||||
# than becoming a second hand-kept list that can drift the same way.
|
||||
networking_types=""
|
||||
for candidate in /usr/lib64/qt6/qml/Quickshell/Networking/quickshell-network.qmltypes \
|
||||
/usr/lib/qt6/qml/Quickshell/Networking/quickshell-network.qmltypes; do
|
||||
[[ -r "$candidate" ]] && { networking_types="$candidate"; break; }
|
||||
done
|
||||
if [[ -z "$networking_types" ]]; then
|
||||
printf 'connectivity contract: NOTE (no Quickshell.Networking qmltypes found; enum members unchecked)\n'
|
||||
else
|
||||
python3 - "$service" "$networking_types" <<'PY' || fail 'the service names enum members that do not exist, which QML resolves to undefined without complaining'
|
||||
import re
|
||||
import sys
|
||||
|
||||
service_path, types_path = sys.argv[1:3]
|
||||
types_text = open(types_path, encoding="utf-8").read()
|
||||
|
||||
# Each exported enum singleton: the name QML sees, and the members it has.
|
||||
enums = {}
|
||||
for block in re.findall(r"Component \{.*?\n \}", types_text, re.S):
|
||||
export = re.search(r'exports: \["Quickshell\.Networking/([A-Za-z0-9_]+) ', block)
|
||||
values = re.search(r"values: \[(.*?)\]", block, re.S)
|
||||
if export and values:
|
||||
enums[export.group(1)] = set(re.findall(r'"([A-Za-z0-9_]+)"', values.group(1)))
|
||||
if not enums:
|
||||
print(f"no enums could be read from {types_path}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
source = "\n".join(line for line in open(service_path, encoding="utf-8")
|
||||
if not line.lstrip().startswith("//"))
|
||||
|
||||
bad = []
|
||||
for enum_name, members in enums.items():
|
||||
for member in set(re.findall(rf"\b{re.escape(enum_name)}\.([A-Za-z0-9_]+)\b", source)):
|
||||
if member not in members:
|
||||
bad.append(f"{enum_name}.{member} (real members: {', '.join(sorted(members))})")
|
||||
|
||||
# A switch on a security type that names no member of the enum at all is the
|
||||
# same failure wearing a different hat, so the reference must be there too.
|
||||
if "securityLabel" in source and not re.search(r"\bWifiSecurityType\.", source):
|
||||
bad.append("securityLabel decides security without naming a WifiSecurityType member")
|
||||
|
||||
if bad:
|
||||
print("\n".join(bad), file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
fi
|
||||
|
||||
if [[ "${PANAMA_CONNECTIVITY_STATIC_ONLY:-0}" == "1" ]]; then
|
||||
printf 'connectivity contract: PASS (static)\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
command -v nmcli >/dev/null || fail 'nmcli is needed to check the service against reality'
|
||||
|
||||
run() { qs -p "$harness" "$@"; }
|
||||
|
||||
@@ -113,6 +113,86 @@ 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'
|
||||
|
||||
# ── 6. Opening something is not the same act as closing it ──────────────────
|
||||
#
|
||||
# The page gained its add side long after its remove side, and the temptation
|
||||
# was to give both the same confirm-then-act shape for symmetry. That would be
|
||||
# wrong, and wrong in the direction that matters: a confirmation dialog is how
|
||||
# this page says "this has a consequence you cannot see from here". Allowing a
|
||||
# port has exactly one consequence, and it is the sentence the user just read on
|
||||
# the button. Spending a confirm on it teaches people to click through the ones
|
||||
# that mean something.
|
||||
#
|
||||
# So: additions go straight through, removals and zone changes do not.
|
||||
grep -q 'Firewall.addService\|addService(' <<<"$page_code" \
|
||||
|| fail 'the page cannot allow a named service, so the firewall is still read-only from here'
|
||||
grep -q 'Firewall.addPort\|addPort(' <<<"$page_code" \
|
||||
|| fail 'the page cannot allow a port'
|
||||
grep -qE 'confirming(Add|Allow|Service|Port)\b' <<<"$page_code" \
|
||||
&& fail 'allowing something asks for a confirmation; that ceremony belongs to the actions that cut people off'
|
||||
# What it must say instead, because both facts are invisible from the button:
|
||||
# the rule outlives a reboot, and firewalld will raise a polkit prompt.
|
||||
grep -qi 'permanent' <<<"$page_code" \
|
||||
|| fail 'the add flow never says the rule is permanent'
|
||||
grep -qi 'ask for your password' <<<"$page_code" \
|
||||
|| fail 'the add flow never warns that the system will ask for a password'
|
||||
|
||||
# A zone change IS consequence-bearing, and its consequence is specific: it
|
||||
# changes which rules apply to one named interface, and every other interface
|
||||
# keeps the zone it had. A confirm that says "change zone?" tells the user
|
||||
# nothing they did not already know, so this pins that the interface is named.
|
||||
grep -q 'setZone' <<<"$page_code" \
|
||||
|| fail 'the page cannot change a connection zone'
|
||||
grep -q 'setDefaultZone' <<<"$page_code" \
|
||||
|| fail 'the page cannot change the default zone'
|
||||
grep -qE '(confirming|pending)(Zone|Interface)' <<<"$page_code" \
|
||||
|| fail 'a connection zone can be changed without confirming, and it decides which rules apply to that link'
|
||||
python3 - "$page" <<'PY' || fail 'the zone-change confirmation does not name the interface it applies to'
|
||||
import re
|
||||
import sys
|
||||
|
||||
lines = open(sys.argv[1], encoding="utf-8").read().splitlines()
|
||||
anchors = [i for i, line in enumerate(lines)
|
||||
if re.search(r"(confirming|pending)(Zone|Interface)", line)]
|
||||
if not anchors:
|
||||
raise SystemExit(1)
|
||||
# Any one of the two-stage anchors may be the one carrying the prose; the
|
||||
# declaration of the state is usually not.
|
||||
for anchor in anchors:
|
||||
window = "\n".join(lines[max(0, anchor - 20):anchor + 60])
|
||||
named = re.search(r"(interface|iface)", window, re.I)
|
||||
interpolated = re.search(r"\$\{|\" \+ |\+ \"", window)
|
||||
if named and interpolated:
|
||||
raise SystemExit(0)
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
|
||||
# ── 7. The zone browser reads and does not write ────────────────────────────
|
||||
#
|
||||
# `zone-info` exists so somebody can look at what a zone would do before moving
|
||||
# an interface into it. A read that can write is not a browser, it is a foot-gun
|
||||
# with a magnifying glass on it.
|
||||
grep -q 'zone-info' "$helper" \
|
||||
|| fail 'the helper cannot describe a zone, so the zone browser has nothing to show'
|
||||
grep -q 'zoneInfo' "$service" \
|
||||
|| fail 'the service does not expose zone descriptions'
|
||||
python3 - "$helper" <<'PY' || fail 'the zone-info path can change the firewall'
|
||||
import re
|
||||
import sys
|
||||
|
||||
source = open(sys.argv[1], encoding="utf-8").read()
|
||||
match = re.search(r"\ndef zone_info\b.*?(?=\ndef |\Z)", source, re.S)
|
||||
if not match:
|
||||
raise SystemExit(1)
|
||||
body = match.group(0)
|
||||
# --info-zone and --list-* are reads. Anything that adds, removes, changes or
|
||||
# makes permanent is not.
|
||||
if re.search(r"--(add|remove|change|set|permanent|reload)", body):
|
||||
print(body[:400], file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
raise SystemExit(0)
|
||||
PY
|
||||
|
||||
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'
|
||||
@@ -142,8 +222,25 @@ if [[ "$(jq -r '.available' <<<"$state")" == "true" ]]; then
|
||||
|| fail 'a loopback-only listener is reported as reachable'
|
||||
fi
|
||||
|
||||
# ── zone-info describes a zone and leaves it exactly as it found it ─────────
|
||||
if [[ "$(jq -r '.available' <<<"$state")" == "true" ]]; then
|
||||
zone_name="$(jq -r '.zones[0].name // ""' <<<"$state")"
|
||||
if [[ -n "$zone_name" ]]; then
|
||||
info="$("$helper" zone-info "$zone_name" 2>/dev/null)" \
|
||||
|| fail "zone-info failed for the zone this machine is actually in ($zone_name)"
|
||||
jq -e '(.services | type == "array") and (.ports | type == "array") and has("summary")' \
|
||||
<<<"$info" >/dev/null \
|
||||
|| fail "zone-info does not describe services, ports and a summary: $info"
|
||||
after="$("$helper" snapshot 2>/dev/null)" || fail 'the snapshot after zone-info failed'
|
||||
[[ "$(jq -cS '.zones' <<<"$after")" == "$(jq -cS '.zones' <<<"$state")" ]] \
|
||||
|| fail 'reading a zone changed the firewall, which is the one thing a browser must not do'
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Refusals ────────────────────────────────────────────────────────────────
|
||||
refusal() { "$helper" "$@" 2>/dev/null | jq -r '.error // ""'; }
|
||||
[[ -n "$(refusal zone-info 'public; reboot')" ]] || fail 'a bad zone name was accepted by zone-info'
|
||||
[[ -n "$(refusal zone-info '')" ]] || fail 'an empty zone name was accepted by zone-info'
|
||||
for bad in "ssh; rm -rf /" "../escape" "" "UPPER CASE"; do
|
||||
[[ -n "$(refusal add-service "$bad")" ]] || fail "a bad service name was accepted: $bad"
|
||||
done
|
||||
|
||||
@@ -33,11 +33,23 @@ fail() {
|
||||
[[ -r "$routes" ]] || fail "missing $routes"
|
||||
|
||||
# GNOME panel names that correspond to a Panama page. Only entries whose panel
|
||||
# genuinely duplicates a Panama page belong here: "network" stays off it because
|
||||
# Panama has no VPN or per-connection routing, and "online-accounts" is listed
|
||||
# because Panama has an Online Accounts page -- but adding an account still has
|
||||
# to go through GOA's own dialog, so that one exception is named explicitly.
|
||||
# genuinely duplicates a Panama page belong here.
|
||||
#
|
||||
# "network" and "wifi" were deliberately kept off this list, on the reason that
|
||||
# Panama had no VPN and no per-connection routing, so GNOME's panel really did
|
||||
# do more. That stopped being true: Connections now carries per-connection
|
||||
# details, forget, autoconnect, MAC randomization, a VPN list with import, a
|
||||
# hotspot, enterprise Wi-Fi, airplane mode and the system proxy. The two rows
|
||||
# that pointed at GNOME were the last thing on that page telling the user to go
|
||||
# somewhere else for something it does, so both panels moved here and the rows
|
||||
# went with them.
|
||||
#
|
||||
# "online-accounts" is listed because Panama has an Online Accounts page -- but
|
||||
# adding an account still has to go through GOA's own dialog, so that one
|
||||
# exception is named explicitly below.
|
||||
declare -A OWNED=(
|
||||
[network]=connectivity
|
||||
[wifi]=connectivity
|
||||
[printers]=printers
|
||||
[online-accounts]=accounts
|
||||
[sharing]=sharing
|
||||
|
||||
@@ -61,8 +61,16 @@ rg -Fq 'implicitHeight: 62' "$settings_dir/HealthCheckRow.qml" \
|
||||
|| fail 'health rows are below the approved 62px target'
|
||||
rg -Fq 'Health.refresh()' "$settings_dir/HealthPage.qml" \
|
||||
|| fail 'opening System Health does not request a fresh scan'
|
||||
rg -Fq 'SystemSettings.openGnomePanel("network")' "$settings_dir/HealthPage.qml" \
|
||||
# "Open GNOME Settings" opens the application, not a panel -- but
|
||||
# gnome-control-center will not start without naming one, so it names the
|
||||
# landing page. It used to name "network", which stopped being honest when
|
||||
# Connections absorbed VPN, hotspot, proxy and per-connection details: Panama
|
||||
# owns that panel now, and gnome-handoff-contract fails any page pointing at an
|
||||
# owned one. "system" is a panel Panama does not have.
|
||||
rg -Fq 'SystemSettings.openGnomePanel("system")' "$settings_dir/HealthPage.qml" \
|
||||
|| fail 'Fedora ownership boundary does not open GNOME Settings'
|
||||
rg -Fq 'SystemSettings.openGnomePanel("network")' "$settings_dir/HealthPage.qml" \
|
||||
&& fail 'System Health lands GNOME Settings on its network panel, which Panama now owns'
|
||||
# Users and Sharing are Panama pages now. A handoff here would send someone to
|
||||
# GNOME for a panel this app owns, which is the opposite of the point -- so the
|
||||
# assertion is inverted rather than deleted.
|
||||
|
||||
Executable
+614
@@ -0,0 +1,614 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Everything Connections learned to do, against a NetworkManager that is not
|
||||
# real.
|
||||
#
|
||||
# This helper is the first one in Panama that handles a secret the user types.
|
||||
# Three of its verbs sit next to a password: joining an enterprise network takes
|
||||
# one, starting a hotspot generates one, and reading a connection's details sits
|
||||
# on top of a store full of them. Each of those is a different way for a
|
||||
# credential to escape:
|
||||
#
|
||||
# * on argv, where /proc publishes it to every process on this machine for as
|
||||
# long as the command runs -- the same failure the Sharing page already
|
||||
# refuses to have (sharing-contract, "the remote desktop password never
|
||||
# passes through Panama");
|
||||
# * in a log line, which outlives the command;
|
||||
# * in the details JSON, which the page renders and which is the one place a
|
||||
# stored PSK would look like it belonged.
|
||||
#
|
||||
# So the pins here are mostly about what must NOT appear, and they are checked
|
||||
# from the data rather than from the source: the stub NetworkManager will hand
|
||||
# over a passphrase to anything that asks for one, and the contract fails if any
|
||||
# of them reaches the JSON, the argv log, or a file the helper wrote.
|
||||
#
|
||||
# The rest is the shape of the commands. `import-vpn` picking its plugin from
|
||||
# the file extension, and the connection-name charset, are both places where a
|
||||
# wrong guess produces a nonsense nmcli invocation rather than an error, so both
|
||||
# are exercised rather than read.
|
||||
#
|
||||
# SAFETY. This runs the real helper, so it must be impossible for it to reach
|
||||
# the real NetworkManager. Four things make that true, and the contract verifies
|
||||
# the first two before running anything:
|
||||
#
|
||||
# 1. every system binary the helper names is resolved through PATH (asserted
|
||||
# statically -- an absolute /usr/bin/nmcli would walk straight past the
|
||||
# stubs), and PATH's first entry is the stub directory;
|
||||
# 2. nmcli, gsettings, rfkill and every D-Bus client are stubbed, each
|
||||
# recording its arguments and its stdin instead of doing anything;
|
||||
# 3. `import gi` resolves to a stand-in on PYTHONPATH whose require_version
|
||||
# always raises, so join-enterprise cannot take its libnm/D-Bus branch and
|
||||
# falls back to nmcli, where the recording can see what it did;
|
||||
# 4. both D-Bus bus addresses point at sockets that do not exist, so anything
|
||||
# that got past (3) still could not connect.
|
||||
#
|
||||
# That means the native libnm branch is not exercised here. It is covered
|
||||
# statically instead, by the check that no command list anywhere in the helper
|
||||
# carries the password -- the failure that branch could have.
|
||||
#
|
||||
# Set PANAMA_NETWORK_STATIC_ONLY=1 to run only the source-reading half, which
|
||||
# touches nothing at all.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
helper="$repo_dir/config/dot/quickshell/scripts/panama-network"
|
||||
service="$repo_dir/config/dot/quickshell/services/NetworkTools.qml"
|
||||
|
||||
fail() {
|
||||
printf 'network tools contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
for path in "$helper" "$service"; do
|
||||
[[ -r "$path" ]] || fail "missing $path"
|
||||
done
|
||||
[[ -x "$helper" ]] || fail 'panama-network is not executable'
|
||||
|
||||
# Sentinels. Every one of these is a value the fake NetworkManager will hand
|
||||
# over, and none of them may come back out.
|
||||
readonly WIFI_PSK='psk-must-never-leave-9c1f'
|
||||
readonly VPN_SECRET='vpn-secret-must-never-leave-7b20'
|
||||
readonly ENTERPRISE_PW='enterprise-pw-must-never-leave-4e88'
|
||||
readonly HOTSPOT_PW='hotspot-pw-must-never-leave-3a55'
|
||||
|
||||
# ── Static: the helper cannot walk past the stubs ────────────────────────────
|
||||
#
|
||||
# Checked first because the dynamic half's safety rests on it. A helper that
|
||||
# spelled its NetworkManager client `/usr/bin/nmcli` would ignore the stub
|
||||
# directory entirely and reconfigure the machine running the test.
|
||||
absolute="$(grep -nE '"/(usr/)?s?bin/[a-z-]+"' "$helper")"
|
||||
[[ -z "$absolute" ]] \
|
||||
|| fail "the helper names a binary by absolute path, so PATH stubs cannot contain it: $absolute"
|
||||
|
||||
# ── Static: reading a connection cannot read a secret ────────────────────────
|
||||
#
|
||||
# By name, in one table, refused in the single function every property read goes
|
||||
# through -- so a field added later cannot quietly become a leak, which a
|
||||
# hand-written allowlist of safe fields would eventually permit.
|
||||
grep -q 'SECRET_PROPERTIES' "$helper" \
|
||||
|| fail 'the secret-holding properties are not named anywhere, so nothing can refuse them'
|
||||
grep -q 'SECRET_SHAPE' "$helper" \
|
||||
|| fail 'only a fixed list guards the secrets; a property NetworkManager adds later would leak until somebody noticed'
|
||||
python3 - "$helper" <<'PY' || fail 'the property reader does not drop the secret properties'
|
||||
import ast
|
||||
import sys
|
||||
|
||||
source = open(sys.argv[1], encoding="utf-8").read()
|
||||
tree = ast.parse(source)
|
||||
|
||||
secrets = set()
|
||||
for node in tree.body:
|
||||
target = ""
|
||||
if isinstance(node, ast.AnnAssign):
|
||||
target = getattr(node.target, "id", "")
|
||||
elif isinstance(node, ast.Assign):
|
||||
target = getattr(node.targets[0], "id", "")
|
||||
if target != "SECRET_PROPERTIES":
|
||||
continue
|
||||
value = node.value
|
||||
# frozenset({...}) / set({...}) is a call wrapped around the literal.
|
||||
if isinstance(value, ast.Call) and value.args:
|
||||
value = value.args[0]
|
||||
try:
|
||||
secrets = set(ast.literal_eval(value))
|
||||
except (ValueError, TypeError):
|
||||
secrets = set()
|
||||
|
||||
# The properties that actually hold a passphrase must be in it, or the table is
|
||||
# decorative.
|
||||
required = {"802-11-wireless-security.psk", "802-1x.password"}
|
||||
if not required <= secrets:
|
||||
print(f"missing from the refusal table: {sorted(required - secrets)}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
# And the filter has to be applied where NetworkManager is read, not at each
|
||||
# use: one place that can be certain, rather than every caller remembering.
|
||||
reader = next((node for node in ast.walk(tree)
|
||||
if isinstance(node, ast.FunctionDef) and node.name == "listing"), None)
|
||||
if reader is None:
|
||||
print("no single function parses nmcli output", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
raise SystemExit(0 if "is_secret" in ast.dump(reader) else 1)
|
||||
PY
|
||||
grep -q -- '--show-secrets' "$helper" \
|
||||
&& fail 'the helper asks NetworkManager to print secrets; the details view has no use for them'
|
||||
|
||||
# ── Static: the enterprise password is read, not passed ──────────────────────
|
||||
#
|
||||
# The mechanism is a choice (libnm's GObject bindings when they are installed, a
|
||||
# scripted `nmcli connection edit` otherwise), but neither may build a command
|
||||
# that carries the password. This is the only check that reaches the libnm
|
||||
# branch, because the dynamic half deliberately disables it.
|
||||
grep -q 'sys.stdin' "$helper" \
|
||||
|| fail 'the enterprise password is never read from stdin'
|
||||
python3 - "$helper" <<'PY' || fail 'a command list in the helper carries the enterprise password'
|
||||
import ast
|
||||
import re
|
||||
import sys
|
||||
|
||||
source = open(sys.argv[1], encoding="utf-8").read()
|
||||
# Names, not values: the value only exists at runtime. Any list literal that
|
||||
# holds a system command and a password-shaped identifier is argv exposure.
|
||||
SECRETISH = re.compile(r"(password|passwd|secret|psk|passphrase)", re.I)
|
||||
COMMANDS = {"nmcli", "gsettings", "rfkill", "gdbus", "busctl", "dbus-send"}
|
||||
bad = []
|
||||
for node in ast.walk(ast.parse(source)):
|
||||
if not isinstance(node, (ast.List, ast.Tuple)):
|
||||
continue
|
||||
literals = {element.value for element in node.elts
|
||||
if isinstance(element, ast.Constant) and isinstance(element.value, str)}
|
||||
if not literals & COMMANDS:
|
||||
continue
|
||||
for element in node.elts:
|
||||
if isinstance(element, ast.Name) and SECRETISH.search(element.id):
|
||||
bad.append(f"line {node.lineno}: {element.id}")
|
||||
elif isinstance(element, ast.Attribute) and SECRETISH.search(element.attr):
|
||||
bad.append(f"line {node.lineno}: .{element.attr}")
|
||||
elif isinstance(element, ast.JoinedStr):
|
||||
for part in ast.walk(element):
|
||||
if isinstance(part, ast.Name) and SECRETISH.search(part.id):
|
||||
bad.append(f"line {node.lineno}: f-string {part.id}")
|
||||
if bad:
|
||||
print("; ".join(bad), file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
|
||||
# ── Static: the service hands the password down the same way ─────────────────
|
||||
grep -q 'PANAMA_NETWORK_HELPER' "$service" \
|
||||
|| fail 'the service has no helper-path seam, so nothing can point it at a stub'
|
||||
grep -q 'function joinEnterprise' "$service" \
|
||||
|| fail 'the service cannot join an enterprise network'
|
||||
python3 - "$service" <<'PY' || fail 'the service puts the enterprise password on the command line'
|
||||
import re
|
||||
import sys
|
||||
|
||||
text = open(sys.argv[1], encoding="utf-8").read()
|
||||
start = text.find("function joinEnterprise")
|
||||
if start < 0:
|
||||
raise SystemExit(1)
|
||||
depth = 0
|
||||
end = start
|
||||
for index in range(text.find("{", start), len(text)):
|
||||
if text[index] == "{":
|
||||
depth += 1
|
||||
elif text[index] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
end = index
|
||||
break
|
||||
body = text[start:end + 1]
|
||||
|
||||
# Every argument list the function builds, checked for a password-shaped name.
|
||||
lists = re.findall(r"\[[^\[\]]*\]", body)
|
||||
if not lists:
|
||||
print("joinEnterprise builds no argument list at all", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
for argv in lists:
|
||||
if re.search(r"(password|secret|passphrase|psk)", argv, re.I):
|
||||
print(argv.strip()[:200], file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
raise SystemExit(0)
|
||||
PY
|
||||
grep -qE '\.write\(|stdinEnabled' "$service" \
|
||||
|| fail 'the service never writes to the helper process stdin, so the password has no way in'
|
||||
|
||||
if [[ "${PANAMA_NETWORK_STATIC_ONLY:-0}" == "1" ]]; then
|
||||
printf 'network tools contract: PASS (static)\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
command -v jq >/dev/null 2>&1 || { printf 'network tools contract: SKIP (no jq)\n'; exit 0; }
|
||||
command -v python3 >/dev/null 2>&1 || { printf 'network tools contract: SKIP (no python3)\n'; exit 0; }
|
||||
|
||||
# ── The fake machine ─────────────────────────────────────────────────────────
|
||||
|
||||
work="$(mktemp -d /tmp/panama-network-contract.XXXXXX)"
|
||||
stub_dir="$work/bin"
|
||||
state_dir="$work/state"
|
||||
home_dir="$work/home"
|
||||
config_home="$work/config"
|
||||
state_home="$work/xdg-state"
|
||||
run_dir="$work/run"
|
||||
pystub="$work/pystub"
|
||||
mkdir -p "$stub_dir" "$state_dir" "$home_dir" "$config_home" "$state_home" "$run_dir" "$pystub/gi"
|
||||
: >"$state_dir/argv"
|
||||
: >"$state_dir/stdin"
|
||||
printf '11111111-0000-0000-0000-000000000001\n22222222-0000-0000-0000-000000000002\n' \
|
||||
>"$state_dir/uuids"
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
|
||||
# The stand-in for PyGObject. libnm's own branch hands the password to
|
||||
# NetworkManager over D-Bus, which is correct and untestable from here -- it
|
||||
# would configure the real machine. Forcing its absence puts the fallback under
|
||||
# test instead, and that is the branch where a password could become an
|
||||
# argument.
|
||||
cat >"$pystub/gi/__init__.py" <<'GISTUB'
|
||||
"""Stand-in for PyGObject, so panama-network takes its nmcli fallback."""
|
||||
|
||||
|
||||
def require_version(namespace, version):
|
||||
raise ValueError(f"Namespace {namespace} not available")
|
||||
GISTUB
|
||||
|
||||
# nmcli, recorded rather than performed. `-g FIELD` is answered from a table
|
||||
# that INCLUDES the secret properties: a helper that asked for a passphrase
|
||||
# would be given one, which is what makes "no secret reached the JSON" a real
|
||||
# result rather than a tautology.
|
||||
cat >"$stub_dir/nmcli" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
state="$state_dir"
|
||||
printf 'nmcli %s\n' "\$*" >>"\$state/argv"
|
||||
joined="\$*"
|
||||
|
||||
# The detail listing INCLUDES the secret properties. A helper that handed its
|
||||
# output straight to the page would leak them, which is what makes "no secret
|
||||
# reached the JSON" a result rather than a tautology.
|
||||
detail() {
|
||||
printf 'connection.id:Home Wi-Fi\n'
|
||||
printf 'connection.uuid:22222222-0000-0000-0000-000000000002\n'
|
||||
printf 'connection.type:802-11-wireless\n'
|
||||
printf 'connection.autoconnect:yes\n'
|
||||
printf '802-11-wireless.cloned-mac-address:random\n'
|
||||
printf '802-11-wireless.ssid:panama-hotspot\n'
|
||||
printf '802-11-wireless.band:bg\n'
|
||||
printf 'GENERAL.STATE:activated\n'
|
||||
printf 'GENERAL.DEVICES:wlp4s0\n'
|
||||
printf 'GENERAL.HWADDR:AA:BB:CC:DD:EE:FF\n'
|
||||
printf 'IP4.ADDRESS[1]:192.168.7.42/24\n'
|
||||
printf 'IP6.ADDRESS[1]:fd00::42/64\n'
|
||||
printf 'IP4.GATEWAY:192.168.7.1\n'
|
||||
printf 'IP4.DNS[1]:192.168.7.1\n'
|
||||
printf 'IP4.DNS[2]:1.1.1.1\n'
|
||||
printf '802-11-wireless-security.psk:$WIFI_PSK\n'
|
||||
printf '802-1x.password:$ENTERPRISE_PW\n'
|
||||
printf 'vpn.secrets.password:$VPN_SECRET\n'
|
||||
}
|
||||
|
||||
case "\$joined" in
|
||||
*"connection edit"*)
|
||||
# The only invocation that is fed anything, and it is drained under a
|
||||
# timeout: every other one inherits whatever stdin the test runner had,
|
||||
# and reading that would hang the suite.
|
||||
timeout 5 cat >>"\$state/stdin" 2>/dev/null || true
|
||||
exit 0 ;;
|
||||
*"connection import"*)
|
||||
printf '33333333-4444-5555-6666-777777777777\n' >>"\$state/uuids"
|
||||
printf "Connection 'imported-profile' (33333333-4444-5555-6666-777777777777) successfully added.\n"
|
||||
exit 0 ;;
|
||||
*"device wifi show-password"*)
|
||||
printf 'SSID: panama-hotspot\n'
|
||||
printf 'Security: WPA2\n'
|
||||
printf 'Password: $HOTSPOT_PW\n'
|
||||
exit 0 ;;
|
||||
*"device wifi hotspot"*)
|
||||
printf "Device 'wlp4s0' successfully activated.\n"
|
||||
exit 0 ;;
|
||||
*"-f UUID connection show"*)
|
||||
cat "\$state/uuids"
|
||||
exit 0 ;;
|
||||
*"-f DEVICE,TYPE device"*)
|
||||
printf 'wlp4s0:wifi\nenp5s0:ethernet\nlo:loopback\n'
|
||||
exit 0 ;;
|
||||
*"device show"*)
|
||||
printf 'GENERAL.HWADDR:AA:BB:CC:DD:EE:FF\n'
|
||||
exit 0 ;;
|
||||
*"connection show "*)
|
||||
detail
|
||||
exit 0 ;;
|
||||
esac
|
||||
exit 0
|
||||
STUB
|
||||
|
||||
cat >"$stub_dir/gsettings" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
printf 'gsettings %s\n' "\$*" >>"$state_dir/argv"
|
||||
case "\$*" in
|
||||
"get org.gnome.system.proxy mode") printf "'none'\n" ;;
|
||||
*" port") printf '0\n' ;;
|
||||
get*) printf "''\n" ;;
|
||||
esac
|
||||
exit 0
|
||||
STUB
|
||||
|
||||
cat >"$stub_dir/rfkill" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
printf 'rfkill %s\n' "\$*" >>"$state_dir/argv"
|
||||
case "\$*" in
|
||||
*-J*|*--json*)
|
||||
printf '{"rfkilldevices":[{"id":0,"type":"wlan","device":"phy0","soft":"unblocked","hard":"unblocked"},{"id":1,"type":"bluetooth","device":"hci0","soft":"unblocked","hard":"unblocked"}]}\n' ;;
|
||||
list*|"")
|
||||
printf '0: phy0: Wireless LAN\n\tSoft blocked: no\n\tHard blocked: no\n'
|
||||
printf '1: hci0: Bluetooth\n\tSoft blocked: no\n\tHard blocked: no\n' ;;
|
||||
esac
|
||||
exit 0
|
||||
STUB
|
||||
|
||||
# Any other route out of this process is closed rather than left open.
|
||||
for blocked in gdbus busctl dbus-send nm-connection-editor nmtui pkexec; do
|
||||
cat >"$stub_dir/$blocked" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
printf '$blocked %s\n' "\$*" >>"$state_dir/argv"
|
||||
printf 'network tools contract: the helper reached for $blocked\n' >&2
|
||||
exit 1
|
||||
STUB
|
||||
done
|
||||
chmod +x "$stub_dir"/*
|
||||
|
||||
runh() {
|
||||
env -i \
|
||||
PATH="$stub_dir:/usr/bin:/bin" \
|
||||
PYTHONPATH="$pystub" \
|
||||
HOME="$home_dir" \
|
||||
XDG_CONFIG_HOME="$config_home" \
|
||||
XDG_STATE_HOME="$state_home" \
|
||||
XDG_RUNTIME_DIR="$run_dir" \
|
||||
DBUS_SESSION_BUS_ADDRESS="unix:path=$run_dir/absent-session-bus" \
|
||||
DBUS_SYSTEM_BUS_ADDRESS="unix:path=$run_dir/absent-system-bus" \
|
||||
LANG=C LC_ALL=C \
|
||||
"$helper" "$@"
|
||||
}
|
||||
|
||||
# The safety claim, verified rather than assumed.
|
||||
for binary in nmcli gsettings rfkill; do
|
||||
resolved="$(env -i PATH="$stub_dir:/usr/bin:/bin" bash -c "command -v $binary")"
|
||||
[[ "$resolved" == "$stub_dir/$binary" ]] \
|
||||
|| fail "$binary resolves to '$resolved', not the stub; refusing to run against the real one"
|
||||
done
|
||||
|
||||
error_of() { runh "$@" 2>/dev/null | jq -r '.error // ""'; }
|
||||
log() { cat "$state_dir/argv"; }
|
||||
leak_in_scratch() {
|
||||
grep -rlF "$1" "$home_dir" "$config_home" "$state_home" "$run_dir" 2>/dev/null | head -1
|
||||
}
|
||||
|
||||
# ── details: the whole point is what is missing ──────────────────────────────
|
||||
|
||||
: >"$state_dir/argv"
|
||||
details="$(runh details 'Home Wi-Fi' 2>/dev/null)" \
|
||||
|| fail 'details failed against the stub NetworkManager'
|
||||
jq -e 'has("ip4") and has("gateway") and has("dns") and has("mac") and has("macRandomized")' \
|
||||
<<<"$details" >/dev/null || fail "details is missing part of its shape: $details"
|
||||
jq -e '.dns | type == "array"' <<<"$details" >/dev/null \
|
||||
|| fail 'dns is not a list, so a second nameserver has nowhere to go'
|
||||
jq -e '(.ip4 | length) > 0 and (.gateway | length) > 0 and (.mac | length) > 0' <<<"$details" >/dev/null \
|
||||
|| fail "details parsed nothing out of the connection listing: $details"
|
||||
jq -e '.macRandomized == true' <<<"$details" >/dev/null \
|
||||
|| fail 'a connection whose cloned MAC is "random" is not reported as randomized'
|
||||
|
||||
for secret in "$WIFI_PSK" "$ENTERPRISE_PW" "$VPN_SECRET"; do
|
||||
grep -Fq "$secret" <<<"$details" \
|
||||
&& fail 'a stored secret reached the details JSON, which the page renders'
|
||||
done
|
||||
# The absence above only means something if the secrets were on offer. They
|
||||
# were: the very listing that produced the address, the gateway and the MAC
|
||||
# carries all three, asked of the fake NetworkManager directly. So that block
|
||||
# says the filter ran, not that there was nothing to filter.
|
||||
served="$(PATH="$stub_dir:$PATH" nmcli -t -e no connection show 'Home Wi-Fi')"
|
||||
for secret in "$WIFI_PSK" "$ENTERPRISE_PW" "$VPN_SECRET"; do
|
||||
grep -Fq "$secret" <<<"$served" \
|
||||
|| fail 'the fake NetworkManager offered no secret, so the details filter was never actually tested'
|
||||
done
|
||||
# Not just the values: a key shaped like a credential is where a future field
|
||||
# would land without anybody noticing.
|
||||
offenders="$(jq -r '[paths | map(tostring) | join(".")]
|
||||
| map(select(test("(password|secret|psk|passphrase)$";"i"))) | join(", ")' <<<"$details")"
|
||||
[[ -z "$offenders" ]] || fail "details carries credential-shaped fields: $offenders"
|
||||
|
||||
# ── forget and the two modifiers ─────────────────────────────────────────────
|
||||
|
||||
: >"$state_dir/argv"
|
||||
runh forget 'Home Wi-Fi' >/dev/null 2>&1
|
||||
grep -Fq 'connection delete Home Wi-Fi' "$state_dir/argv" \
|
||||
|| fail "forget did not delete the connection profile: $(log)"
|
||||
|
||||
: >"$state_dir/argv"
|
||||
runh set-autoconnect 'Home Wi-Fi' true >/dev/null 2>&1
|
||||
grep -Fq 'connection modify' "$state_dir/argv" \
|
||||
|| fail "set-autoconnect did not modify the profile: $(log)"
|
||||
grep -Eq 'connection\.autoconnect +(yes|true)' "$state_dir/argv" \
|
||||
|| fail "set-autoconnect did not set connection.autoconnect: $(log)"
|
||||
: >"$state_dir/argv"
|
||||
runh set-autoconnect 'Home Wi-Fi' false >/dev/null 2>&1
|
||||
grep -Eq 'connection\.autoconnect +(no|false)' "$state_dir/argv" \
|
||||
|| fail "turning autoconnect off did not reach nmcli as a negative: $(log)"
|
||||
|
||||
: >"$state_dir/argv"
|
||||
mac_on="$(runh set-mac-random 'Home Wi-Fi' true 2>/dev/null)"
|
||||
grep -Eq 'cloned-mac-address +random' "$state_dir/argv" \
|
||||
|| fail "randomizing the MAC did not set the cloned address: $(log)"
|
||||
# The setting does nothing until the connection comes back up, and a switch
|
||||
# that appears to have taken effect when it has not is the whole bug.
|
||||
grep -qi 'reconnect' <<<"$mac_on" \
|
||||
|| fail "set-mac-random does not say a reconnect is needed: $mac_on"
|
||||
: >"$state_dir/argv"
|
||||
runh set-mac-random 'Home Wi-Fi' false >/dev/null 2>&1
|
||||
grep -Eq 'cloned-mac-address +permanent' "$state_dir/argv" \
|
||||
|| fail "turning randomization off did not restore the permanent address: $(log)"
|
||||
|
||||
# ── import-vpn picks its plugin from the extension ───────────────────────────
|
||||
|
||||
printf '[Interface]\n' >"$work/tunnel.conf"
|
||||
printf 'client\n' >"$work/tunnel.ovpn"
|
||||
printf 'not a tunnel\n' >"$work/tunnel.txt"
|
||||
|
||||
: >"$state_dir/argv"
|
||||
imported="$(runh import-vpn "$work/tunnel.conf" 2>/dev/null)"
|
||||
grep -Fq 'connection import type wireguard' "$state_dir/argv" \
|
||||
|| fail "a .conf file was not imported as WireGuard: $(log)"
|
||||
jq -e '((.name // "") | length > 0) and ((.uuid // "") | length > 0)' <<<"$imported" >/dev/null \
|
||||
|| fail "import-vpn does not name the connection it made: $imported"
|
||||
jq -e '.kind == "wireguard"' <<<"$imported" >/dev/null \
|
||||
|| fail "a .conf import is not reported as WireGuard: $imported"
|
||||
|
||||
: >"$state_dir/argv"
|
||||
ovpn="$(runh import-vpn "$work/tunnel.ovpn" 2>/dev/null)"
|
||||
grep -Fq 'connection import type openvpn' "$state_dir/argv" \
|
||||
|| fail "a .ovpn file was not imported as OpenVPN: $(log)"
|
||||
jq -e '.kind == "openvpn"' <<<"$ovpn" >/dev/null \
|
||||
|| fail "a .ovpn import is not reported as OpenVPN: $ovpn"
|
||||
|
||||
: >"$state_dir/argv"
|
||||
[[ -n "$(error_of import-vpn "$work/tunnel.txt")" ]] \
|
||||
|| fail 'a file that is neither .conf nor .ovpn was accepted for import'
|
||||
grep -Fq 'connection import' "$state_dir/argv" \
|
||||
&& fail 'an unimportable file still reached nmcli'
|
||||
[[ -n "$(error_of import-vpn "$work/does-not-exist.conf")" ]] \
|
||||
|| fail 'a path that does not exist was accepted for import'
|
||||
|
||||
# ── hotspot: the password comes back once and is written nowhere ─────────────
|
||||
|
||||
: >"$state_dir/argv"
|
||||
hotspot="$(runh hotspot start panama-hotspot 2>/dev/null)"
|
||||
grep -Fq 'device wifi hotspot' "$state_dir/argv" \
|
||||
|| fail "starting a hotspot did not reach nmcli: $(log)"
|
||||
grep -Fq 'device wifi show-password' "$state_dir/argv" \
|
||||
|| fail 'the generated hotspot password is never read back, so the UI cannot show it'
|
||||
grep -Fq "$HOTSPOT_PW" <<<"$hotspot" \
|
||||
|| fail "the hotspot password is not returned to the caller: $hotspot"
|
||||
# It may pass through the return value exactly once. It may not be an argument,
|
||||
# and it may not be left behind on disk.
|
||||
grep -Fq "$HOTSPOT_PW" "$state_dir/argv" \
|
||||
&& fail 'the hotspot password was passed to a command, where /proc publishes it'
|
||||
leaked="$(leak_in_scratch "$HOTSPOT_PW")"
|
||||
[[ -z "$leaked" ]] || fail "the hotspot password was written to $leaked"
|
||||
|
||||
runh hotspot status >/dev/null 2>&1 || fail 'hotspot status failed'
|
||||
: >"$state_dir/argv"
|
||||
runh hotspot stop >/dev/null 2>&1
|
||||
[[ -s "$state_dir/argv" ]] || fail 'stopping the hotspot did nothing at all'
|
||||
|
||||
# ── join-enterprise: the password arrives on stdin and never on argv ─────────
|
||||
#
|
||||
# The pin the whole file is built around. libnm is unavailable here by
|
||||
# construction, so this is the nmcli fallback: the editor takes `set
|
||||
# 802-1x.password …` as a line of input, which keeps the secret out of ps.
|
||||
|
||||
: >"$state_dir/argv"
|
||||
: >"$state_dir/stdin"
|
||||
enterprise_out="$(printf '%s\n' "$ENTERPRISE_PW" \
|
||||
| runh join-enterprise 'Campus Secure' peap-mschapv2 '[email protected]' 2>"$work/enterprise.err")"
|
||||
|
||||
grep -Fq "$ENTERPRISE_PW" "$state_dir/argv" \
|
||||
&& fail 'the enterprise password was passed as a command argument'
|
||||
grep -Fq "$ENTERPRISE_PW" <<<"$enterprise_out" \
|
||||
&& fail 'the enterprise password is echoed back in the helper output'
|
||||
grep -Fq "$ENTERPRISE_PW" "$work/enterprise.err" \
|
||||
&& fail 'the enterprise password was written to stderr'
|
||||
leaked="$(leak_in_scratch "$ENTERPRISE_PW")"
|
||||
[[ -z "$leaked" ]] || fail "the enterprise password was written to $leaked"
|
||||
# And it did reach NetworkManager, or the verb is a no-op wearing a JSON hat.
|
||||
grep -Fq 'connection edit' "$state_dir/argv" \
|
||||
|| fail "join-enterprise never opened the connection editor: $(log)"
|
||||
grep -Fq "$ENTERPRISE_PW" "$state_dir/stdin" \
|
||||
|| fail 'the enterprise password never reached nmcli at all, on stdin or otherwise'
|
||||
|
||||
# The EAP method is a closed set, not free text handed to nmcli. Nothing is
|
||||
# piped in on purpose: the arguments are checked before stdin is read, so a
|
||||
# request that was always going to be refused must not sit waiting for a
|
||||
# password first.
|
||||
: >"$state_dir/argv"
|
||||
bad_method="$(runh join-enterprise 'Campus Secure' ldap-md5 'gib' </dev/null 2>/dev/null \
|
||||
| jq -r '.error // ""')"
|
||||
[[ -n "$bad_method" ]] || fail 'an unknown EAP method was accepted'
|
||||
grep -Fq 'connection edit' "$state_dir/argv" \
|
||||
&& fail 'an unknown EAP method still reached nmcli'
|
||||
[[ -n "$(runh join-enterprise 'Campus Secure' peap-mschapv2 'gib' </dev/null 2>/dev/null \
|
||||
| jq -r '.error // ""')" ]] \
|
||||
|| fail 'an enterprise join with no password was accepted'
|
||||
|
||||
# ── proxy and airplane are the settings they claim to be ─────────────────────
|
||||
|
||||
: >"$state_dir/argv"
|
||||
proxy="$(runh proxy get 2>/dev/null)"
|
||||
grep -Fq 'org.gnome.system.proxy' "$state_dir/argv" \
|
||||
|| fail "proxy get does not read the GNOME proxy settings: $(log)"
|
||||
jq -e 'has("mode")' <<<"$proxy" >/dev/null || fail "proxy get reports no mode: $proxy"
|
||||
|
||||
: >"$state_dir/argv"
|
||||
runh proxy set manual 192.168.7.9 3128 >/dev/null 2>&1
|
||||
grep -Fq 'set org.gnome.system.proxy mode' "$state_dir/argv" \
|
||||
|| fail "setting a manual proxy did not set the mode: $(log)"
|
||||
# All three or none: a proxy applied to http alone silently leaks the rest.
|
||||
for scheme in http https socks; do
|
||||
grep -Fq "org.gnome.system.proxy.$scheme host" "$state_dir/argv" \
|
||||
|| fail "the manual proxy was not applied to $scheme, so that traffic ignores it"
|
||||
done
|
||||
[[ -n "$(error_of proxy set sideways)" ]] || fail 'an unknown proxy mode was accepted'
|
||||
[[ -n "$(error_of proxy set manual 'host; reboot' 3128)" ]] \
|
||||
|| fail 'a proxy host with a shell metacharacter was accepted'
|
||||
[[ -n "$(error_of proxy set manual 192.168.7.9 99999)" ]] \
|
||||
|| fail 'an impossible proxy port was accepted'
|
||||
[[ -n "$(error_of proxy set auto 'javascript:alert(1)')" ]] \
|
||||
|| fail 'a PAC URL that is not a URL was accepted'
|
||||
|
||||
: >"$state_dir/argv"
|
||||
airplane="$(runh airplane status 2>/dev/null)"
|
||||
grep -Fq 'rfkill' "$state_dir/argv" || fail "airplane status does not read rfkill: $(log)"
|
||||
jq -e 'has("on")' <<<"$airplane" >/dev/null || fail "airplane status reports no state: $airplane"
|
||||
: >"$state_dir/argv"
|
||||
runh airplane set true >/dev/null 2>&1
|
||||
grep -Eq 'rfkill +block' "$state_dir/argv" \
|
||||
|| fail "turning airplane mode on did not block the radios: $(log)"
|
||||
: >"$state_dir/argv"
|
||||
runh airplane set false >/dev/null 2>&1
|
||||
grep -Eq 'rfkill +unblock' "$state_dir/argv" \
|
||||
|| fail "turning airplane mode off did not unblock the radios: $(log)"
|
||||
[[ -n "$(error_of airplane set maybe)" ]] || fail 'a non-boolean was accepted for airplane mode'
|
||||
|
||||
# ── Names are validated here, not by nmcli ───────────────────────────────────
|
||||
#
|
||||
# The reason matters: without validation these still fail, because nmcli refuses
|
||||
# them too -- so a check that only asks "did something error" passes with the
|
||||
# validation deleted. Each must also leave the recording untouched, which is
|
||||
# what says the refusal happened before anything was run.
|
||||
|
||||
long_name="$(printf 'a%.0s' {1..300})"
|
||||
for bad in 'Home; rm -rf /' '-x-not-a-name' '' "$long_name" 'new
|
||||
line'; do
|
||||
: >"$state_dir/argv"
|
||||
[[ -n "$(error_of details "$bad")" ]] \
|
||||
|| fail "a bad connection name was accepted: ${bad@Q}"
|
||||
[[ ! -s "$state_dir/argv" ]] \
|
||||
|| fail "a bad connection name reached nmcli before being refused: ${bad@Q}"
|
||||
[[ -n "$(error_of forget "$bad")" ]] \
|
||||
|| fail "forget accepted a bad connection name: ${bad@Q}"
|
||||
done
|
||||
for bad in 'ssid; reboot' '-x-not-a-name' '' "$(printf 'a%.0s' {1..33})"; do
|
||||
: >"$state_dir/argv"
|
||||
[[ -n "$(error_of hotspot start "$bad")" ]] \
|
||||
|| fail "a bad hotspot SSID was accepted: ${bad@Q}"
|
||||
[[ ! -s "$state_dir/argv" ]] \
|
||||
|| fail "a bad hotspot SSID reached nmcli before being refused: ${bad@Q}"
|
||||
done
|
||||
[[ -n "$(error_of set-autoconnect 'Home Wi-Fi' perhaps)" ]] \
|
||||
|| fail 'a non-boolean was accepted for autoconnect'
|
||||
[[ -n "$(error_of bogus-verb)" ]] || fail 'an unknown command was accepted'
|
||||
|
||||
# ── Nothing anywhere left a secret behind ───────────────────────────────────
|
||||
for secret in "$WIFI_PSK" "$VPN_SECRET" "$ENTERPRISE_PW" "$HOTSPOT_PW"; do
|
||||
leaked="$(leak_in_scratch "$secret")"
|
||||
[[ -z "$leaked" ]] || fail "a secret was left behind in $leaked"
|
||||
done
|
||||
|
||||
printf 'network tools contract: PASS (details, forget, autoconnect, MAC, import, hotspot, enterprise, proxy, airplane)\n'
|
||||
@@ -62,6 +62,90 @@ for scheme in file pipe; do
|
||||
&& fail "the $scheme scheme is allowed, and it does not lead to a printer"
|
||||
done
|
||||
|
||||
# ── Printer options are a short closed list, not a passthrough ──────────────
|
||||
#
|
||||
# `lpadmin -o` is the same door the driver ban closed, reopened from the side.
|
||||
# Anything can be set through it -- including ppd-name, and including options
|
||||
# that make a printer accept jobs and print nothing -- so the page offers two
|
||||
# settings, and the helper knows both of them by name and by value. A caller's
|
||||
# string is never spliced into `-o`; it is looked up, and refused when absent.
|
||||
grep -qE '^OPTION_[A-Z_]+\s*[:=]' "$helper" \
|
||||
|| fail 'the settable options are not declared in one place, so "closed vocabulary" cannot be checked'
|
||||
python3 - "$helper" <<'PY' || fail 'the option vocabulary is not the closed media/sides set this page promises'
|
||||
import ast
|
||||
import sys
|
||||
|
||||
source = open(sys.argv[1], encoding="utf-8").read()
|
||||
vocabulary = {}
|
||||
for node in ast.parse(source).body:
|
||||
if isinstance(node, ast.AnnAssign):
|
||||
name = getattr(node.target, "id", "")
|
||||
value = node.value
|
||||
elif isinstance(node, ast.Assign):
|
||||
name = getattr(node.targets[0], "id", "")
|
||||
value = node.value
|
||||
else:
|
||||
continue
|
||||
if not name.startswith("OPTION_") or value is None:
|
||||
continue
|
||||
try:
|
||||
literal = ast.literal_eval(value)
|
||||
except ValueError:
|
||||
continue
|
||||
# A key-to-choices table, not the spelling table beside it: only the dict
|
||||
# whose values are collections of choices describes what may be set.
|
||||
if isinstance(literal, dict) and literal and all(
|
||||
isinstance(choices, (list, tuple, set)) for choices in literal.values()):
|
||||
vocabulary.update(literal)
|
||||
|
||||
if set(vocabulary) != {"media", "sides"}:
|
||||
print(f"settable keys are {sorted(vocabulary)}, expected ['media', 'sides']", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
if set(vocabulary["media"]) != {"Letter", "A4", "Legal"}:
|
||||
print(f"media values are {sorted(vocabulary['media'])}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
if set(vocabulary["sides"]) != {"one-sided", "two-sided-long-edge", "two-sided-short-edge"}:
|
||||
print(f"sides values are {sorted(vocabulary['sides'])}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
raise SystemExit(0)
|
||||
PY
|
||||
# And set-option must reach for that vocabulary rather than trusting its
|
||||
# arguments. The values are validated in one place or they are validated
|
||||
# nowhere.
|
||||
python3 - "$helper" <<'OPTIONS' || fail 'set-option does not check its key and value against the closed vocabulary'
|
||||
import re
|
||||
import sys
|
||||
|
||||
source = open(sys.argv[1], encoding="utf-8").read()
|
||||
match = re.search(r"\ndef set_option\b.*?(?=\ndef |\Z)", source, re.S)
|
||||
if not match:
|
||||
raise SystemExit(1)
|
||||
raise SystemExit(0 if re.search(r"OPTION_[A-Z_]+", match.group(0)) else 1)
|
||||
OPTIONS
|
||||
grep -q 'get-options' "$helper" || fail 'the helper cannot read the options a printer is set to'
|
||||
grep -q 'set-option' "$helper" || fail 'the helper cannot set a printer option'
|
||||
|
||||
# ── A queued job can be held and let go again ───────────────────────────────
|
||||
#
|
||||
# Cancel was the only verb, so the only way to stop a job going out on the
|
||||
# wrong paper was to throw it away and print it again.
|
||||
for verb in hold release; do
|
||||
grep -q "\"$verb\"" "$helper" || fail "the helper has no $verb verb"
|
||||
grep -qi "$verb" <<<"$page_code" || fail "the queue offers no $verb"
|
||||
done
|
||||
grep -qi 'paper size\|media' <<<"$page_code" \
|
||||
|| fail 'the expanded printer offers no paper size'
|
||||
grep -qi 'two-sided\|sides' <<<"$page_code" \
|
||||
|| fail 'the expanded printer offers no two-sided setting'
|
||||
# The dropdowns must show what the printer is set to, not a guess.
|
||||
grep -q 'getOptions\|optionsFor\|options(' "$service" \
|
||||
|| fail 'the service never reads printer options, so the dropdowns would be showing defaults'
|
||||
|
||||
# The empty state is one card. It was two rows saying the same thing, which read
|
||||
# as two different things you could try.
|
||||
[[ "$(grep -c 'Search the network' <<<"$page_code")" -le 1 ]] \
|
||||
|| fail 'the "Search the network" row appears more than once'
|
||||
|
||||
command -v jq >/dev/null 2>&1 || { printf 'printers contract: SKIP (no jq)\n'; exit 0; }
|
||||
|
||||
refusal() { "$helper" "$@" 2>/dev/null | jq -r '.error // ""'; }
|
||||
@@ -80,6 +164,26 @@ for bad in "../escape" "has space" "a#b" ""; do
|
||||
|| fail "the helper accepted \"$bad\" as a printer name"
|
||||
done
|
||||
[[ -n "$(refusal cancel notanumber)" ]] || fail 'the helper accepted a job id that is not a number'
|
||||
for verb in hold release; do
|
||||
[[ -n "$(refusal "$verb" notanumber)" ]] \
|
||||
|| fail "the helper accepted a job id that is not a number for $verb"
|
||||
done
|
||||
# Options: a key outside the vocabulary, and a value outside its own key's list.
|
||||
# Both are refused before anything reaches lpadmin, so running this touches no
|
||||
# real printer.
|
||||
# The reason again, not merely an error: the printer name is only checked for
|
||||
# shape here, so these reach the vocabulary and are refused by it rather than by
|
||||
# CUPS -- which means nothing is sent to a real printer either.
|
||||
[[ "$(refusal set-option office-laser ppd-name everywhere)" == "That is not a setting this page changes." ]] \
|
||||
|| fail 'an option outside the vocabulary was not refused by the vocabulary, which reopens the driver door from the side'
|
||||
[[ "$(refusal set-option office-laser media Tabloid)" == "That is not a value this setting accepts." ]] \
|
||||
|| fail 'a paper size outside the offered list was not refused by the vocabulary'
|
||||
[[ "$(refusal set-option office-laser sides sideways)" == "That is not a value this setting accepts." ]] \
|
||||
|| fail 'a two-sided value outside the offered list was not refused by the vocabulary'
|
||||
[[ -n "$(refusal set-option '../escape' media Letter)" ]] \
|
||||
|| fail 'set-option accepted a bad printer name'
|
||||
[[ -n "$(refusal get-options 'has space')" ]] \
|
||||
|| fail 'get-options accepted a bad printer name'
|
||||
[[ -n "$(refusal bogus-command)" ]] || fail 'an unknown command was accepted'
|
||||
|
||||
# ── The snapshot describes the machine ──────────────────────────────────────
|
||||
@@ -94,6 +198,30 @@ jq -e '[.printers[] | has("name") and has("state") and has("isDefault")] | all'
|
||||
[[ "$(jq '[.printers[] | select(.isDefault)] | length' <<<"$snapshot")" -le 1 ]] \
|
||||
|| fail 'more than one printer is reported as the default'
|
||||
|
||||
# ── Reading a printer's options is a read ───────────────────────────────────
|
||||
# Only the keys the page can write are reported: a dropdown that lists an
|
||||
# option nothing can set is a control that does nothing.
|
||||
first_printer="$(jq -r '.printers[0].name // ""' <<<"$snapshot")"
|
||||
if [[ -n "$first_printer" ]]; then
|
||||
options="$("$helper" get-options "$first_printer" 2>/dev/null)" \
|
||||
|| fail "get-options failed for $first_printer"
|
||||
jq -e '(.options | has("media") and has("sides")) and (.choices | has("media") and has("sides"))' \
|
||||
<<<"$options" >/dev/null \
|
||||
|| fail "get-options does not report the two settings the page offers, with their choices: $options"
|
||||
extra="$(jq -r '[(.options | keys[]), (.choices | keys[])]
|
||||
| unique | map(select(. != "media" and . != "sides")) | join(", ")' <<<"$options")"
|
||||
[[ -z "$extra" ]] \
|
||||
|| fail "get-options offers keys the page cannot set: $extra"
|
||||
# Every offered choice has to be one set-option would accept, or the
|
||||
# dropdown lists something that is refused the moment it is chosen.
|
||||
bad_choice="$(jq -r '
|
||||
(.choices.media // []) - ["Letter","A4","Legal"]
|
||||
+ ((.choices.sides // []) - ["one-sided","two-sided-long-edge","two-sided-short-edge"])
|
||||
| join(", ")' <<<"$options")"
|
||||
[[ -z "$bad_choice" ]] \
|
||||
|| fail "get-options offers values outside the vocabulary: $bad_choice"
|
||||
fi
|
||||
|
||||
# ── Removal is confirmed ────────────────────────────────────────────────────
|
||||
grep -q 'confirmingRemoval' "$page" \
|
||||
|| fail 'the page removes a printer without a confirmation step'
|
||||
|
||||
@@ -9,7 +9,11 @@ fail() {
|
||||
exit 1
|
||||
}
|
||||
|
||||
pages=(Home MyHome Phone Displays Connectivity Bar Dock ControlCenter Tiling Workspaces Sync Sound Shortcuts Mouse Dictation Notifications Focus ScreenIntelligence Health About)
|
||||
# Firewall, Printers and Sharing joined this list when the Network & Sharing
|
||||
# rebuild touched all four Connections-category pages at once: three of them had
|
||||
# never been checked for the page scaffold at all, and a rebuild is exactly when
|
||||
# a hand-rolled Flickable comes back.
|
||||
pages=(Home MyHome Phone Displays Connectivity Firewall Printers Sharing Bar Dock ControlCenter Tiling Workspaces Sync Sound Shortcuts Mouse Dictation Notifications Focus ScreenIntelligence Health About)
|
||||
for page in "${pages[@]}"; do
|
||||
page_file="$repo_dir/config/dot/quickshell/modules/settings/${page}Page.qml"
|
||||
[[ -f "$page_file" ]] || fail "${page}Page.qml is missing"
|
||||
@@ -29,6 +33,32 @@ for page in "${pages[@]}"; do
|
||||
|| fail "${page}Page.qml still copies the page Flickable scaffold"
|
||||
done
|
||||
|
||||
settings_dir="$repo_dir/config/dot/quickshell/modules/settings"
|
||||
|
||||
# A page's objectName is how the running shell is asked to point at it, and how
|
||||
# a contract finds it without walking the visual tree. The four pages under
|
||||
# Connections all carry the id their route uses.
|
||||
while IFS='|' read -r page_file object_name; do
|
||||
rg -Fq "objectName: \"$object_name\"" "$settings_dir/$page_file" \
|
||||
|| fail "$page_file does not carry objectName \"$object_name\""
|
||||
done <<'OBJECTS'
|
||||
ConnectivityPage.qml|connectivity
|
||||
FirewallPage.qml|firewall
|
||||
PrintersPage.qml|printers
|
||||
SharingPage.qml|sharing
|
||||
OBJECTS
|
||||
|
||||
# A component file that exists but is not exported from qmldir is not a missing
|
||||
# import error at startup -- it is an unresolved type at the moment the row is
|
||||
# first rendered, which is to say when somebody expands a connection.
|
||||
qmldir="$settings_dir/qmldir"
|
||||
for component in ConnectionDetails EnterpriseJoinForm; do
|
||||
[[ -f "$settings_dir/$component.qml" ]] \
|
||||
|| fail "$component.qml is missing"
|
||||
rg -Fq "$component 1.0 $component.qml" "$qmldir" \
|
||||
|| fail "$component.qml is not exported from qmldir, so the type resolves to nothing when it is first used"
|
||||
done
|
||||
|
||||
require_row() {
|
||||
local file="$1"
|
||||
local row_type="$2"
|
||||
|
||||
@@ -89,6 +89,52 @@ grep -q 'kitty' "$service" \
|
||||
grep -q 'clear-rdp-credentials' "$helper" \
|
||||
|| fail 'stored credentials cannot be cleared'
|
||||
|
||||
# ── The page says the true thing in the right place ─────────────────────────
|
||||
#
|
||||
# Three copy rules, each of which was a real failure before it was a rule.
|
||||
#
|
||||
# The failure banner floated above every card as a full-width red bar, so a
|
||||
# grdctl error that concerned one row repainted the whole page as broken. It
|
||||
# belongs inside the card whose action failed -- the machine card, which is
|
||||
# where refresh and the hostname live.
|
||||
page_code="$(grep -vE '^\s*//' "$page")"
|
||||
python3 - "$page" <<'PY' || fail 'the failure message is not inside the first card, so one row failing reads as the page failing'
|
||||
import re
|
||||
import sys
|
||||
|
||||
lines = open(sys.argv[1], encoding="utf-8").read().splitlines()
|
||||
start = next((i for i, line in enumerate(lines) if re.search(r"\bSettingsCard\s*\{", line)), None)
|
||||
if start is None:
|
||||
raise SystemExit(1)
|
||||
depth = 0
|
||||
end = None
|
||||
for index in range(start, len(lines)):
|
||||
depth += lines[index].count("{") - lines[index].count("}")
|
||||
if depth <= 0:
|
||||
end = index
|
||||
break
|
||||
if end is None:
|
||||
raise SystemExit(1)
|
||||
card = "\n".join(lines[start:end + 1])
|
||||
raise SystemExit(0 if "Sharing.lastError" in card else 1)
|
||||
PY
|
||||
|
||||
# The terminal is the mechanism, not the explanation. "Opens kitty" tells
|
||||
# somebody the name of a program they did not ask about and still leaves them
|
||||
# wondering why a settings page cannot take a password; the reason it cannot is
|
||||
# the sentence worth printing.
|
||||
! grep -qi 'kitty' <<<"$page_code" \
|
||||
|| fail 'the page names the terminal application in text the user reads; that belongs to the service'
|
||||
grep -q 'never passes through Panama' <<<"$page_code" \
|
||||
|| fail 'the credentials row does not say why the password is set elsewhere'
|
||||
|
||||
# A row for software that is not here has to be worth reading. "Not installed"
|
||||
# on its own is a dead end; what installing it would give you is not.
|
||||
grep -q 'install it and this becomes a switch' <<<"$page_code" \
|
||||
|| fail 'the file sharing row does not say what installing Samba would unlock'
|
||||
grep -q 'does not install software' <<<"$page_code" \
|
||||
|| fail 'the file sharing row does not say that Settings will not install it for you'
|
||||
|
||||
# ── The snapshot reflects the machine ───────────────────────────────────────
|
||||
command -v jq >/dev/null 2>&1 || { printf 'sharing contract: SKIP (no jq)\n'; exit 0; }
|
||||
snapshot="$("$helper" snapshot 2>/dev/null)" || fail 'snapshot failed'
|
||||
|
||||
Reference in New Issue
Block a user