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:
Gabriel Brown
2026-08-24 16:31:52 -04:00
parent aba2d16ffa
commit b30bf40407
29 changed files with 4452 additions and 241 deletions
+114
View File
@@ -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" "$@"; }
+97
View File
@@ -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
+16 -4
View File
@@ -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
+9 -1
View File
@@ -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.
+614
View File
@@ -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'
+128
View File
@@ -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'
+31 -1
View File
@@ -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"
+46
View File
@@ -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'