Files
Panama/tests/quickshell/network-tools-contract
T

862 lines
41 KiB
Bash
Executable File

#!/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'
readonly HIDDEN_PW='hidden-pw-must-never-leave-8d13'
# ── 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 address validators exist, and every verb has a shape ─────────
#
# A static address that NetworkManager refuses is a connection that comes up
# with no address at all, which is a worse failure than being told to retype it
# -- so the refusal has to happen here, before nmcli is called. Named rather
# than only exercised, because the dynamic half below can only ever prove that
# SOME check ran, not that the right family's check did.
for pattern in IPV4 IPV6 IPV4_PREFIX IPV6_PREFIX; do
grep -qE "^${pattern} = re\.compile" "$helper" \
|| fail "no $pattern pattern, so a static address is whatever NetworkManager will take"
done
# Every verb has to be in shape_for AND in FALLBACKS, or a refusal comes back
# in a shape the page cannot read -- the reason a page never has to branch on
# whether the reply is an error.
python3 - "$helper" <<'PY' || fail 'a verb has no reply shape, or a shape has no fallback'
import ast
import sys
source = open(sys.argv[1], encoding="utf-8").read()
tree = ast.parse(source)
shapes = {}
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef) and node.name == "shape_for":
for inner in ast.walk(node):
if isinstance(inner, ast.Dict):
shapes = {key.value: value.value for key, value in
zip(inner.keys, inner.values)
if isinstance(key, ast.Constant) and isinstance(value, ast.Constant)}
fallbacks = set()
for node in tree.body:
target = getattr(node.targets[0], "id", "") if isinstance(node, ast.Assign) else ""
if target == "FALLBACKS" and isinstance(node.value, ast.Dict):
fallbacks = {key.value for key in node.value.keys if isinstance(key, ast.Constant)}
required = {"details", "forget", "saved", "set-autoconnect", "set-mac-random",
"set-metered", "set-ip", "join-enterprise", "join-hidden",
"import-vpn", "hotspot", "proxy", "airplane"}
missing = sorted(required - set(shapes))
if missing:
print(f"shape_for does not know: {missing}", file=sys.stderr)
raise SystemExit(1)
orphans = sorted(set(shapes.values()) - fallbacks)
if orphans:
print(f"shapes with no FALLBACKS entry: {orphans}", file=sys.stderr)
raise SystemExit(1)
raise SystemExit(0)
PY
# ── 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 ─────────────────────────────────────────────────────────
#
# The field list the saved-profile stub answers to, read from the helper rather
# than retyped: a stub that answered a list the helper no longer asks for would
# quietly stop being consulted, and the in-range assertions below would pass on
# an empty table.
SAVED_FIELDS="$(sed -nE 's/^SAVED_FIELDS = "([^"]+)"$/\1/p' "$helper")"
[[ -n "$SAVED_FIELDS" ]] || fail 'the helper names no field list for the saved profiles'
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'
# What the PROFILE asks for, as distinct from what is on the wire above.
# The editor reads these; a helper that reported only the active state
# would show an empty form over a static address.
printf 'connection.metered:unknown\n'
printf 'ipv4.method:auto\n'
printf 'ipv4.addresses:--\n'
printf 'ipv4.gateway:--\n'
printf 'ipv4.dns:--\n'
printf 'ipv6.method:auto\n'
printf 'ipv6.addresses:--\n'
printf 'ipv6.gateway:--\n'
printf 'ipv6.dns:--\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
*"-f $SAVED_FIELDS"*)
# The saved-profile table: NAME last, so a network called "Cafe: Guest"
# survives the field separator.
printf '22222222-0000-0000-0000-000000000002:802-11-wireless:yes:yes:1700000000:Home Wi-Fi\n'
printf '44444444-0000-0000-0000-000000000004:802-11-wireless:yes:no:1600000000:Office-Corp\n'
printf '55555555-0000-0000-0000-000000000005:802-11-wireless:no:no:1500000000:Cafe: Guest\n'
printf '66666666-0000-0000-0000-000000000006:802-3-ethernet:yes:no:1400000000:Wired connection 1\n'
exit 0 ;;
*"-f SSID device wifi list"*)
# Only one of the saved networks is anywhere near this machine, which is
# the whole point of the in-range flag.
printf 'Home Wi-Fi\nCoffeeHaus_Guest\n'
exit 0 ;;
*"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)"
# ── details reports the profile, not only the wire ───────────────────────────
#
# The editor writes the profile's addressing, so it has to be able to read it.
# The four fields at the top of `details` are what the connection currently
# holds, which is a different question: a static address that has not been
# applied yet is in the profile and not on the wire, and an editor bound to the
# wire would show an empty form over a setting somebody just typed.
: >"$state_dir/argv"
profile="$(runh details 'Home Wi-Fi' 2>/dev/null)"
jq -e 'has("metered") and has("ip4Method") and has("ip4Addresses")
and has("ip4Gateway") and has("ip4Dns") and has("ip6Method")' \
<<<"$profile" >/dev/null || fail "details does not report the profile's own addressing: $profile"
jq -e '.metered == "auto"' <<<"$profile" >/dev/null \
|| fail "NetworkManager's 'unknown' metered flag is not reported as automatic: $profile"
# "--" is nmcli for "unset", and a page that rendered it would show two dashes
# in the gateway box.
jq -e '.ip4Gateway == "" and (.ip4Dns | length) == 0' <<<"$profile" >/dev/null \
|| fail "an unset profile property came through as nmcli's placeholder: $profile"
# ── set-metered is three states, not two ─────────────────────────────────────
: >"$state_dir/argv"
runh set-metered 'Home Wi-Fi' yes >/dev/null 2>&1
grep -Eq 'connection\.metered +yes' "$state_dir/argv" \
|| fail "marking a connection metered did not reach nmcli: $(log)"
: >"$state_dir/argv"
runh set-metered 'Home Wi-Fi' auto >/dev/null 2>&1
# "automatic" is NetworkManager deciding, which it spells "unknown". Sending it
# "auto" would be refused, and sending it "no" would report a guess as a fact.
grep -Eq 'connection\.metered +unknown' "$state_dir/argv" \
|| fail "leaving metered to NetworkManager did not send its own spelling: $(log)"
: >"$state_dir/argv"
[[ -n "$(error_of set-metered 'Home Wi-Fi' sometimes)" ]] \
|| fail 'an unknown metered state was accepted'
[[ ! -s "$state_dir/argv" ]] || fail 'an unknown metered state still reached nmcli'
# ── set-ip writes a whole stack, and validates before it does ────────────────
: >"$state_dir/argv"
runh set-ip 'Home Wi-Fi' 4 manual 192.168.7.50/24 192.168.7.1 '1.1.1.1,9.9.9.9' >/dev/null 2>&1
for setting in 'ipv4\.method +manual' 'ipv4\.addresses +192\.168\.7\.50/24' \
'ipv4\.gateway +192\.168\.7\.1' 'ipv4\.dns +1\.1\.1\.1,9\.9\.9\.9'; do
grep -Eq "$setting" "$state_dir/argv" \
|| fail "a manual IPv4 address did not set $setting: $(log)"
done
# Without this NetworkManager appends DHCP's nameservers to the ones just
# typed, so "manual DNS" silently becomes "manual DNS and whatever else".
grep -Eq 'ipv4\.ignore-auto-dns +yes' "$state_dir/argv" \
|| fail "manual DNS does not ignore the ones DHCP hands out: $(log)"
# It was active in the listing, so it has to come back up or the change is
# saved and invisible.
grep -Eq 'connection up Home Wi-Fi' "$state_dir/argv" \
|| fail "an active connection was not reactivated after its address changed: $(log)"
: >"$state_dir/argv"
runh set-ip 'Home Wi-Fi' 6 manual 'fd00::42/64' 'fd00::1' 'fd00::1' >/dev/null 2>&1
grep -Eq 'ipv6\.method +manual' "$state_dir/argv" \
|| fail "a manual IPv6 address did not set the IPv6 method: $(log)"
grep -Fq 'fd00::42/64' "$state_dir/argv" \
|| fail "the IPv6 address never reached nmcli: $(log)"
# Going back to automatic has to CLEAR what manual left behind, or the
# connection comes up holding both.
: >"$state_dir/argv"
runh set-ip 'Home Wi-Fi' 4 auto >/dev/null 2>&1
grep -Eq 'ipv4\.method +auto' "$state_dir/argv" \
|| fail "returning to DHCP did not set the method: $(log)"
grep -Eq 'ipv4\.addresses' "$state_dir/argv" \
|| fail "returning to DHCP left the static address in place: $(log)"
grep -Eq 'ipv4\.ignore-auto-dns +no' "$state_dir/argv" \
|| fail "returning to DHCP kept ignoring the nameservers it hands out: $(log)"
# Each of these must be refused BEFORE nmcli, for the reason at the top of the
# name-validation block: nmcli refuses them too, so a check that only asks "did
# something error" passes with the validation deleted.
while IFS='|' read -r family address gateway dns why; do
: >"$state_dir/argv"
[[ -n "$(error_of set-ip 'Home Wi-Fi' "$family" manual "$address" "$gateway" "$dns")" ]] \
|| fail "$why was accepted"
[[ ! -s "$state_dir/argv" ]] || fail "$why reached nmcli before being refused"
done <<'BAD'
4|192.168.7.50|192.168.7.1|1.1.1.1|an address with no prefix
4|999.1.1.1/24|192.168.7.1|1.1.1.1|an address whose octets are not octets
4|192.168.7.50/33|192.168.7.1|1.1.1.1|an IPv4 prefix past 32
6|fd00::42/129|fd00::1|fd00::1|an IPv6 prefix past 128
4|192.168.7.50/24|not-a-gateway|1.1.1.1|a gateway that is not an address
4|192.168.7.50/24|192.168.7.1|nameserver|a nameserver that is not an address
6|192.168.7.50/24|fd00::1|fd00::1|an IPv4 address typed into the IPv6 stack
BAD
: >"$state_dir/argv"
[[ -n "$(error_of set-ip 'Home Wi-Fi' 5 auto)" ]] || fail 'a third IP family was accepted'
[[ -n "$(error_of set-ip 'Home Wi-Fi' 4 sideways)" ]] \
|| fail 'an addressing mode that is neither automatic nor manual was accepted'
# ── saved: the profiles this machine holds, in range or not ─────────────────
: >"$state_dir/argv"
saved="$(runh saved 2>/dev/null)"
jq -e '(.connections | length) == 4' <<<"$saved" >/dev/null \
|| fail "the saved listing did not parse four profiles: $saved"
# NAME is read last precisely so this one survives: nothing else in the row can
# contain a colon.
jq -e '[.connections[].name] | index("Cafe: Guest") != null' <<<"$saved" >/dev/null \
|| fail "a network name containing a colon was cut in half: $saved"
jq -e '.connections[] | select(.name == "Home Wi-Fi")
| .active == true and .autoconnect == true and .inRange == true' <<<"$saved" >/dev/null \
|| fail "the connected profile is not reported as connected and in range: $saved"
# The whole reason for the scan cross-reference: a saved network you are
# nowhere near is otherwise invisible until you stand next to it.
jq -e '.connections[] | select(.name == "Office-Corp") | .inRange == false' <<<"$saved" >/dev/null \
|| fail "a saved network that the scan did not see is not reported as out of range: $saved"
# A wired profile is not somewhere else; it is a cable. Saying "out of range"
# about one would be inventing a fact.
jq -e '.connections[] | select(.name == "Wired connection 1") | .inRange == null' <<<"$saved" >/dev/null \
|| fail "a wired profile was given an in-range answer, which it cannot have: $saved"
grep -Fq -- '--rescan no' "$state_dir/argv" \
|| fail "listing saved profiles made the radio go looking: $(log)"
offenders="$(jq -r '[paths | map(tostring) | join(".")]
| map(select(test("(password|secret|psk|passphrase)$";"i"))) | join(", ")' <<<"$saved")"
[[ -z "$offenders" ]] || fail "the saved listing carries credential-shaped fields: $offenders"
# ── join-hidden: the same stdin rule as the enterprise join ─────────────────
: >"$state_dir/argv"
: >"$state_dir/stdin"
hidden_out="$(printf '%s\n' "$HIDDEN_PW" \
| runh join-hidden 'office-private' 'office-private' wpa-psk 2>"$work/hidden.err")"
grep -Fq "$HIDDEN_PW" "$state_dir/argv" \
&& fail 'the hidden network passphrase was passed as a command argument'
grep -Fq "$HIDDEN_PW" <<<"$hidden_out" \
&& fail 'the hidden network passphrase is echoed back in the helper output'
grep -Fq "$HIDDEN_PW" "$work/hidden.err" \
&& fail 'the hidden network passphrase was written to stderr'
leaked="$(leak_in_scratch "$HIDDEN_PW")"
[[ -z "$leaked" ]] || fail "the hidden network passphrase was written to $leaked"
grep -Fq "$HIDDEN_PW" "$state_dir/stdin" \
|| fail 'the hidden network passphrase never reached nmcli at all, on stdin or otherwise'
# Without this the profile saves and never connects: NetworkManager only probes
# for a network by name when it is told the name is not broadcast.
grep -Fq '802-11-wireless.hidden yes' "$state_dir/stdin" \
|| fail 'the profile is not marked hidden, so NetworkManager will never look for it'
grep -Fq 'connection up office-private' "$state_dir/argv" \
|| fail "join-hidden saved a profile and never brought it up: $(log)"
# An open hidden network is a real thing, and it has no passphrase to wait for.
: >"$state_dir/argv"
: >"$state_dir/stdin"
runh join-hidden 'open-hidden' 'open-hidden' none </dev/null >/dev/null 2>&1
grep -Fq 'connection edit' "$state_dir/argv" \
|| fail "an open hidden network was not created: $(log)"
grep -Fq 'wireless-security' "$state_dir/stdin" \
&& fail 'an open network was given a key-management setting'
: >"$state_dir/argv"
[[ -n "$(runh join-hidden 'office-private' 'office-private' wep </dev/null 2>/dev/null \
| jq -r '.error // ""')" ]] || fail 'an unknown hidden-network security was accepted'
[[ ! -s "$state_dir/argv" ]] \
|| fail 'an unknown hidden-network security still reached nmcli'
[[ -n "$(runh join-hidden 'office-private' 'office-private' wpa-psk </dev/null 2>/dev/null \
| jq -r '.error // ""')" ]] || fail 'a secured hidden network with no password was accepted'
# ── 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" "$HIDDEN_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, saved, autoconnect, MAC, metered, static IP, import, hotspot, enterprise, hidden, proxy, airplane)\n'