Shortcuts you invent, rules you write, gestures you own - all still just data

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-25 01:51:59 -04:00
parent f9e5d3f470
commit 06c53d6c21
48 changed files with 4749 additions and 140 deletions
+204 -10
View File
@@ -1,15 +1,27 @@
#!/usr/bin/env bash
# The touchpad gestures, and the window swallowing beside them.
# The touchpad gestures, and the two pieces of looks.lua beside them.
#
# Both are behaviour that only exists on hardware this machine may not have, so
# neither can be checked by running it. What can be pinned is the shape:
# All of it is behaviour that cannot be observed without hardware this machine
# may not have, or without a compositor -- a swipe, a window swallowing
# another, a shader over the whole screen. What can be pinned is the shape:
#
# * Three gestures, mirroring GNOME. Sideways moves workspaces, up opens the
# overview, down closes it.
# * Three THREE-finger gestures, mirroring GNOME. Sideways moves workspaces,
# up opens the overview, down closes it. These are the desktop's and are
# written as literals, so they can be read here character by character.
# * Up and down do not both toggle. That is the obvious way to write it and it
# is wrong: swiping up from an open overview would close it, and swiping
# down would reopen it, which is the opposite of what the fingers mean.
# * FOUR-finger gestures are the user's, from settings.json, and are emitted
# only inside the loop that reads them -- one per direction that resolves to
# a valid named action, and none at all for a direction nobody assigned.
# That last part is not tidiness: a gesture registration is read at config
# time and cannot be removed afterwards, so a no-op gesture per direction
# would consume the four-finger swipes permanently.
# * Every color filter the settings page offers names a shader the repository
# actually ships. `decoration:screen_shader` is a path Hyprland compiles at
# config time, so a missing one is a shader compile failure on a
# whole-screen pass rather than a feature that quietly does nothing.
# * Swallowing is off by default and driven by a preference. Turning it on for
# everybody would make terminals appear to vanish on a machine nobody asked.
# * The swallow regex names only terminals this desktop ships. Anything the
@@ -19,17 +31,19 @@
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
input="$repo_dir/config/dot/hypr/input.lua"
looks="$repo_dir/config/dot/hypr/looks.lua"
hypr_dir="$repo_dir/config/dot/hypr"
input="$hypr_dir/input.lua"
looks="$hypr_dir/looks.lua"
schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml"
findings=()
note() { findings+=("$1"); }
# ── Gestures ─────────────────────────────────────────────────────────────────
# ── The three that are the desktop's ─────────────────────────────────────────
gestures="$(grep -c 'hl\.gesture({' "$input" || true)"
(( gestures == 3 )) || note "input.lua registers $gestures gestures, not the 3 that mirror GNOME"
literals="$(grep -c 'hl\.gesture({ fingers = 3' "$input" || true)"
(( literals == 3 )) \
|| note "input.lua registers $literals three-finger gestures, not the 3 that mirror GNOME"
grep -qE 'fingers = 3, direction = "horizontal",[[:space:]]*action = "workspace"' "$input" \
|| note 'three fingers sideways does not switch workspaces'
@@ -43,6 +57,111 @@ if grep -qE 'direction = "(up|down)",[[:space:]]*action = overview\("toggle"\)'
note 'a vertical gesture toggles the overview, so swiping the same way twice undoes itself'
fi
# ── The four that are the user's ─────────────────────────────────────────────
#
# Exactly one hl.gesture call beyond the three literals, and it lives inside the
# loop over the preferences. A second call site is a four-finger gesture emitted
# from somewhere the settings file does not control, which is a gesture nobody
# can take back.
calls="$(grep -c 'hl\.gesture({' "$input" || true)"
(( calls == 4 )) \
|| note "input.lua has $calls hl.gesture call sites; expected the 3 literals plus one in the loop"
grep -q 'custom_gestures' "$input" \
|| note 'the four-finger gestures are not read from a table of preferences'
python3 - "$input" <<'PY' || note 'a four-finger gesture is registered outside the loop that reads the preferences'
import re, sys
lines = open(sys.argv[1], encoding="utf-8").read().splitlines()
in_loop = False
problems = []
for number, line in enumerate(lines, 1):
if re.match(r"^for .*custom_gestures", line):
in_loop = True
elif re.match(r"^end\b", line):
in_loop = False
if "fingers = 4" in line and not in_loop:
problems.append(f"line {number}: {line.strip()[:70]}")
if problems:
print("\n".join(problems), file=sys.stderr)
raise SystemExit(1)
PY
# ── What the Lua actually emits ──────────────────────────────────────────────
#
# The checks above read the source. This runs it, against a stubbed `hl` and a
# synthetic settings file, so no compositor is touched and no real preference is
# read -- and so the validation in actions.lua is exercised rather than assumed.
if command -v lua >/dev/null 2>&1; then
work="$(mktemp -d /tmp/panama-gestures.XXXXXX)"
trap 'rm -rf "$work"' EXIT
mkdir -p "$work/config/panama"
# The fingers count of every gesture input.lua registers, one per line.
emit() {
printf '%s' "$1" >"$work/config/panama/settings.json"
XDG_CONFIG_HOME="$work/config" lua -e "
package.path = '$hypr_dir/?.lua;' .. package.path
hl = {
config = function() end,
exec_cmd = function() end,
dispatch = function() end,
gesture = function(spec) print(spec.fingers .. ' ' .. tostring(spec.direction)) end,
}
dofile('$input')
" 2>/dev/null
}
four_fingers() { grep -c '^4 ' <<<"$1" || true; }
three_fingers() { grep -c '^3 ' <<<"$1" || true; }
# Nothing assigned: the three shipped gestures and not one more.
unassigned="$(emit '{}')"
(( "$(three_fingers "$unassigned")" == 3 )) \
|| note 'the three shipped gestures are not registered on a machine with no settings file'
(( "$(four_fingers "$unassigned")" == 0 )) \
|| note 'a four-finger gesture is registered with nothing assigned to it'
empty="$(emit '{"gestureFourUp":{},"gestureFourDown":{},"gestureFourLeft":{},"gestureFourRight":{}}')"
(( "$(four_fingers "$empty")" == 0 )) \
|| note 'an explicitly unassigned direction still registers a gesture'
# Two assigned, one per kind the vocabulary offers.
assigned="$(emit '{"gestureFourUp":{"kind":"shell","target":"overview","label":"Overview"},
"gestureFourLeft":{"kind":"window","target":"float-toggle","label":"Toggle float"}}')"
(( "$(four_fingers "$assigned")" == 2 )) \
|| note "two assigned directions produced $(four_fingers "$assigned") four-finger gestures"
grep -q '^4 up$' <<<"$assigned" || note 'an assigned four-finger up gesture was not registered'
grep -q '^4 left$' <<<"$assigned" || note 'an assigned four-finger left gesture was not registered'
(( "$(three_fingers "$assigned")" == 3 )) \
|| note 'assigning a four-finger gesture changed the three shipped ones'
# A stored value the whitelists do not recognise resolves to nothing. This
# is the property that keeps a hand-editable file from being executable: a
# command in `target` is not a command, it is an unknown key.
hostile="$(emit '{"gestureFourUp":{"kind":"shell","target":"rm -rf /","label":"x"},
"gestureFourDown":{"kind":"exec","target":"sh","label":"x"},
"gestureFourLeft":{"kind":"window","target":"workspace:99","label":"x"},
"gestureFourRight":{"kind":"app","target":"foo; reboot","label":"x"}}')"
(( "$(four_fingers "$hostile")" == 0 )) \
|| note 'an unrecognised or unsafe named action was registered as a gesture anyway'
(( "$(three_fingers "$hostile")" == 3 )) \
|| note 'a malformed gesture preference cost the shipped gestures'
# A malformed file must cost the customizations and nothing else.
for broken in '{"gestureFourUp":"overview"}' '{"gestureFourUp":[1,2]}' '{"gestureFourUp":null}'; do
(( "$(three_fingers "$(emit "$broken")")" == 3 )) \
|| note "a malformed gesture preference took input.lua down: $broken"
done
rm -rf "$work"
trap - EXIT
else
note 'lua is not installed, so what the gestures actually emit went unchecked'
fi
# The tuning that Hyprland does accept at runtime has to be reachable, or the
# gestures are unadjustable without editing this file -- which is the thing
# Settings exists to avoid.
@@ -51,6 +170,81 @@ for key in swipeDistance swipeInvert; do
|| note "$key is not a preference, so the gestures cannot be tuned from Settings"
done
# ── Color filters ────────────────────────────────────────────────────────────
#
# looks.lua's other unrunnable half, and it sits here for the same reason
# swallowing does: it is behaviour whose effect cannot be observed without a
# compositor, but whose shape can be read.
#
# `decoration:screen_shader` is a PATH, and Hyprland compiles what it finds
# there at config time. A named shader the repository does not ship is not a
# filter that quietly does nothing -- it is a shader compile failure on a
# whole-screen pass, which is a much worse afternoon than a missing feature.
# So every enum the preference offers must name a file that exists.
filters="$(sed -n '/^local colorFilters = {/,/^}/p' "$looks" | grep -oE '[a-z]+\.frag')"
[[ -n "$filters" ]] || note 'looks.lua has no enum-to-shader table, so the color filter preference reaches nothing'
while IFS= read -r shader; do
[[ -n "$shader" ]] || continue
[[ -f "$repo_dir/config/dot/hypr/shaders/$shader" ]] \
|| note "looks.lua names shaders/$shader, which the repository does not ship"
done <<<"$filters"
# Every option the schema offers is mapped, and nothing is mapped that the
# schema does not offer. An unmapped enum is a filter the settings page lets
# you pick and the compositor never applies.
schema_options="$(sed -n '/key: "colorFilter"/,/^ },/p' "$schema" \
| grep -oE 'value: "[a-z]+"' | sed -E 's/value: "(.*)"/\1/' | grep -v '^none$')"
[[ -n "$schema_options" ]] || note 'the colorFilter schema entry offers no filters'
while IFS= read -r option; do
[[ -n "$option" ]] || continue
grep -q "^$option\.frag$" <<<"$filters" \
|| note "the schema offers the '$option' filter, but looks.lua maps it to no shader"
done <<<"$schema_options"
# "none" must not be in the table: it is the absence of a filter, and mapping it
# to a shader would make turning the feature off cost a full-screen pass.
grep -q '^none\.frag$' <<<"$filters" \
&& note 'looks.lua maps the "none" filter to a shader, so turning the filter off still runs one'
# The stored value is the enum, never the path. Storing the path would put a
# filesystem location into a hand-editable preference and make the stored value
# disagree with what hyprctl reports back.
if grep -q 'key: "colorFilter"' "$schema"; then
# Comments stripped first: the entry explains at length why it has no
# hypr: block, and the explanation contains the words it is denying.
block="$(sed -n '/key: "colorFilter"/,/^ },/p' "$schema" | grep -v '^[[:space:]]*//')"
grep -q 'hypr:' <<<"$block" \
&& note 'colorFilter declares a hypr option, but hyprctl stores a shader path rather than this enum'
else
note 'colorFilter is not in the schema'
fi
# Every shader is an end-of-pipe fragment shader in the form Hyprland's own
# example uses. A shader missing `tex` or its output samples nothing and paints
# nothing, which on a whole-screen pass is a black desktop.
for shader in "$repo_dir"/config/dot/hypr/shaders/*.frag; do
[[ -e "$shader" ]] || continue
name="$(basename "$shader")"
grep -q '^#version 300 es' "$shader" \
|| note "shaders/$name does not declare the GLSL version Hyprland compiles screen shaders as"
grep -q 'uniform sampler2D tex;' "$shader" \
|| note "shaders/$name never samples the screen"
grep -q 'in vec2 v_texcoord;' "$shader" \
|| note "shaders/$name does not take the screen coordinate Hyprland provides"
grep -qE 'out vec4 fragColor;' "$shader" \
|| note "shaders/$name declares no output, so it paints nothing"
grep -q 'fragColor =' "$shader" \
|| note "shaders/$name never writes its output"
# Alpha is carried through rather than assumed opaque: the pass runs over
# the composited frame, and forcing it to 1.0 is how a filter comes to
# paint over things that were meant to be see-through.
grep -q 'pixColor.a' "$shader" \
|| note "shaders/$name discards the alpha channel instead of carrying it through"
done
# ── Swallowing ───────────────────────────────────────────────────────────────
grep -qE 'enable_swallow = prefs\.get\("windowSwallow", false\)' "$looks" \
+18 -1
View File
@@ -45,6 +45,16 @@ grep -q 'write_categories()' "$keybinds" \
grep -q 'if file == nil then' "$keybinds" \
|| note 'the manifest writer does not tolerate being unable to open the file'
# The user's own shortcuts are a category too, and they are the one group whose
# membership is not written in this file. They are emitted last and outside the
# `bind` wrapper -- deliberately, so keybindOverrides (which is keyed by a
# SHIPPED chord) can never reach one -- which means they would be invisible to
# the manifest unless the loop records the category itself.
grep -q 'category("Custom")' "$keybinds" \
|| note 'custom shortcuts are emitted without a category, so they land in Other'
grep -q 'customBinds' "$keybinds" \
|| note 'keybinds.lua never reads customBinds, so shortcuts the user invents are not bound'
# The shell prefers the authored category and still works without one.
grep -q 'categoryManifest' "$service" \
|| note 'the shell never reads the category manifest'
@@ -100,7 +110,14 @@ if [[ -r "$manifest" ]]; then
# Every category is one the shell knows how to order. A typo produces a
# group that sorts last and looks like a bug in the cheatsheet.
known='Windows Workspaces Applications Shell Session Media & hardware Other'
#
# Custom is the one group the user fills: shortcuts they invented, read
# from customBinds at the end of keybinds.lua. It is legitimately empty on
# a machine nobody has customized and legitimately full on one somebody
# has, so unlike Other it carries no count expectation -- only the
# requirement that it be a name the shell orders (Keybinds.qml's groupOrder
# puts it first) rather than an unknown that sorts last.
known='Windows Workspaces Applications Shell Session Media & hardware Custom Other'
while read -r value; do
[[ -n "$value" ]] || continue
grep -qF "$value" <<<"$known" \
+217
View File
@@ -0,0 +1,217 @@
#!/usr/bin/env bash
# Per-application window rules, written by the user.
#
# This is settings.json describing compositor behaviour, which is the same
# shape of risk custom shortcuts carry and is handled the same way: the stored
# entry is data -- a class, some booleans, two numbers -- and hypr/rules.lua is
# the only thing that turns it into a rule.
#
# Three properties, and every one of them was a real way to lose the desktop:
#
# 1. The class is matched LITERALLY. Hyprland matches with RE2, so a class
# typed into a text field is a regular expression: "org.gnome.Files" would
# also match "orgxgnomexFiles", and a half-typed "(" is a pattern error
# rather than a rule that matches nothing.
# 2. An invalid entry is skipped WHOLE. A rule that half-applies -- the size
# dropped, the float kept -- is harder to understand than one that is not
# there.
# 3. The shell's own surfaces cannot be matched. A user floating or moving
# Quickshell's windows from the Windows page is a person breaking their
# desktop with a supported control.
#
# And one about ordering: user rules are ANONYMOUS. Hyprland evaluates every
# named rule before every anonymous one, so naming a user rule would make it
# lose to the shipped rules it is meant to override.
#
# The Lua is exercised with a stubbed `hl`, so the rules can be counted without
# a compositor and without touching the running desktop.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
hypr_dir="$repo_dir/config/dot/hypr"
rules="$hypr_dir/rules.lua"
schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml"
findings=()
note() { findings+=("$1"); }
command -v lua >/dev/null 2>&1 || {
printf 'window rules contract: lua is not installed\n' >&2
exit 1
}
work="$(mktemp -d /tmp/panama-window-rules.XXXXXX)"
trap 'rm -rf "$work"' EXIT
mkdir -p "$work/config/panama"
# Every window rule rules.lua emits for a given settings file, one per line, as
# "<name or -> <class pattern> <field=value ...>". Layer rules are swallowed:
# this is about window rules, and a layer rule is not one.
emit() {
printf '%s' "$1" >"$work/config/panama/settings.json"
XDG_CONFIG_HOME="$work/config" lua -e "
package.path = '$hypr_dir/?.lua;' .. package.path
hl = {
layer_rule = function() end,
window_rule = function(rule)
local fields = {}
for key, value in pairs(rule) do
if key ~= 'match' and key ~= 'name' then
if type(value) == 'table' then
value = tostring(value[1]) .. 'x' .. tostring(value[2])
end
fields[#fields + 1] = key .. '=' .. tostring(value)
end
end
table.sort(fields)
local class = (rule.match or {}).class
print((rule.name or '-') .. ' ' .. tostring(class) .. ' ' .. table.concat(fields, ' '))
end,
}
dofile('$rules')
" 2>/dev/null
}
count() { grep -c . <<<"$1" || true; }
# ── The shipped baseline ─────────────────────────────────────────────────────
#
# Read rather than pinned as a number. Pinning one means every new shipped rule
# fails this contract for no reason, and the property under test is the
# DIFFERENCE the user's rules make, not how many rules Panama ships.
shipped="$(emit '{"windowRules":[]}')"
shipped_count="$(count "$shipped")"
(( shipped_count > 0 )) || note 'rules.lua emitted no window rules at all, which cannot be right'
absent="$(emit '{}')"
(( "$(count "$absent")" == shipped_count )) \
|| note 'with no windowRules key at all, the shipped rule count changes'
# ── Five rules, two of which must not be emitted ──────────────────────────────
#
# * Calculator -- valid, and the one whose escaping is checked
# * qs-dock -- the shell's own surface, refused
# * Steam -- workspace 42, which does not exist; skipped whole
# * foo(bar -- a class that is not a valid regex, and must not become one
# * mygame -- valid, with every remaining field set
USER_RULES='{"windowRules":[
{"class":"org.gnome.Calculator","label":"Calculator","float":true,"center":true,"size":[400,600]},
{"class":"qs-dock","label":"Dock","float":true},
{"class":"Steam","label":"Steam","workspace":42},
{"class":"foo(bar","label":"Odd","float":true,"pin":true},
{"class":"mygame","label":"Game","game":true,"noAnim":true,"noDim":true,"workspace":9}
]}'
applied="$(emit "$USER_RULES")"
applied_count="$(count "$applied")"
(( applied_count == shipped_count + 3 )) \
|| note "five user rules with two invalid emitted $(( applied_count - shipped_count )) rules, not 3"
user_lines="$(comm -13 <(sort <<<"$shipped") <(sort <<<"$applied"))"
# 1. The class is escaped and anchored, so it matches itself and nothing else.
grep -qF '^org\.gnome\.Calculator$' <<<"$user_lines" \
|| note 'the class is not regex-escaped, so a dotted application id matches classes the user did not name'
grep -qF '^foo\(bar$' <<<"$user_lines" \
|| note 'a class containing a regex metacharacter is not escaped, so it is a pattern rather than a name'
# 2. Invalid entries are gone, and gone whole.
grep -q 'qs-dock' <<<"$user_lines" \
&& note 'a rule matching the shell\047s own surfaces was emitted'
grep -q 'Steam' <<<"$user_lines" \
&& note 'a rule with an out-of-range workspace was emitted'
grep -qE 'Steam.*float' <<<"$user_lines" \
&& note 'an invalid rule was emitted with its bad field dropped rather than skipped whole'
# 3. The valid ones carry what they were given, under Hyprland's own names.
grep -qE '\^org\\\.gnome\\\.Calculator\$.*center=true.*float=true.*size=400x600' <<<"$user_lines" \
|| note 'the float/center/size rule did not survive translation: '"$(grep Calculator <<<"$user_lines")"
grep -qE '\^mygame\$.*content=game.*no_anim=true.*no_dim=true.*workspace=9' <<<"$user_lines" \
|| note 'the game rule did not survive translation: '"$(grep mygame <<<"$user_lines")"
grep -qE '\^foo\\\(bar\$.*pin=true' <<<"$user_lines" \
|| note 'pin did not survive translation'
# 4. Anonymous, and after the shipped rules. Named would outrank every
# anonymous shipped rule; earlier would let a shipped rule win the last-match
# tiebreak against the user's own.
while IFS= read -r line; do
[[ -n "$line" ]] || continue
[[ "${line%% *}" == "-" ]] \
|| note "a user rule was emitted with the name '${line%% *}', which makes it outrank the shipped rules"
done <<<"$user_lines"
first_user="$(grep -n 'mygame' <<<"$applied" | head -1 | cut -d: -f1)"
last_shipped="$(grep -c . <<<"$shipped")"
[[ -n "$first_user" && "$first_user" -gt "$last_shipped" ]] \
|| note 'user rules are not emitted after every shipped rule'
# ── Sizes and workspaces have bounds ─────────────────────────────────────────
#
# Not decoration: a 4-pixel window is unreachable with the pointer, and a
# workspace number outside what the keybinds reach strands a window somewhere
# there is no shortcut to.
for bad in \
'{"class":"tiny","size":[4,4]}' \
'{"class":"huge","size":[99999,99999]}' \
'{"class":"half","size":[400]}' \
'{"class":"zero","workspace":0}' \
'{"class":"","float":true}' \
'{"class":"quickshell","float":true}' \
'{"class":"QS-Dock","float":true}'; do
out="$(emit "{\"windowRules\":[$bad]}")"
(( "$(count "$out")" == shipped_count )) \
|| note "an invalid rule was emitted anyway: $bad"
done
# A class of exactly the cap is fine; one past it is not.
ok_class="$(printf 'a%.0s' $(seq 1 128))"
long_class="$(printf 'a%.0s' $(seq 1 129))"
(( "$(count "$(emit "{\"windowRules\":[{\"class\":\"$ok_class\"}]}")")" == shipped_count + 1 )) \
|| note 'a class of exactly 128 characters is refused, so the cap is off by one'
(( "$(count "$(emit "{\"windowRules\":[{\"class\":\"$long_class\"}]}")")" == shipped_count )) \
|| note 'a class longer than 128 characters is emitted anyway'
# ── Nothing malformed can cost the compositor ────────────────────────────────
#
# The whole point of reading a hand-editable file at config time is that a bad
# read costs the setting and never the desktop. A raise here aborts rules.lua,
# and the desktop comes up with no window rules at all.
for hostile in \
'{"windowRules":"not an array"}' \
'{"windowRules":[null]}' \
'{"windowRules":[42]}' \
'{"windowRules":[{"class":123}]}' \
'{"windowRules":[{"class":"ok","size":"400x600"}]}' \
'{"windowRules":[{"class":"ok","workspace":"3"}]}'; do
out="$(emit "$hostile")"
(( "$(count "$out")" >= shipped_count )) \
|| note "a malformed windowRules value cost the shipped rules: $hostile"
done
# ── The preference cannot pretend to be an option ────────────────────────────
if grep -q 'key: "windowRules"' "$schema"; then
block="$(sed -n '/key: "windowRules"/,/^ },/p' "$schema")"
grep -q 'hypr:' <<<"$block" \
&& note 'windowRules declares a hypr option, but window rules are not settable options'
else
note 'windowRules is not in the schema'
fi
# ── Report ───────────────────────────────────────────────────────────────────
if (( ${#findings[@]} > 0 )); then
mapfile -t findings < <(printf '%s\n' "${findings[@]}" | sort -u)
printf 'window rules contract: %d finding(s)\n' "${#findings[@]}" >&2
printf ' - %s\n' "${findings[@]}" >&2
exit 1
fi
printf 'window rules contract: PASS (%d shipped rules, user rules validated and escaped)\n' "$shipped_count"
+10
View File
@@ -70,7 +70,17 @@ fail() {
# locales, so a row pointing at it is now a door out of a page that does the
# thing. The retired page carried two: that one, and an "Open appearance"
# handoff left over from when the fonts lived here.
#
# "system" is the umbrella. System Health's Fedora card used to lead with a
# button reading "Open GNOME Settings" that landed on GNOME's System panel as a
# generic front door -- not a handoff for anything in particular, which is
# exactly why nothing here caught it while every specific door was being closed
# one at a time. By the end it pointed at an application whose Users, Sharing,
# Printers, Online Accounts, Privacy, Region, Colour and Network panels all have
# Panama pages. It maps to About, which is the page that answers "what is this
# machine" -- the last honest reason anyone opened that panel.
declare -A OWNED=(
[system]=about
[network]=connectivity
[wifi]=connectivity
[printers]=printers
+18 -8
View File
@@ -78,14 +78,19 @@ 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'
# "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.
# The umbrella button is gone, and this is the assertion that keeps it gone.
#
# It read "Open GNOME Settings" and landed on the System panel -- not a handoff
# for anything in particular, which is exactly why nothing caught it while every
# specific door beside it was being closed one at a time. It first named
# "network", then "system" when Connections absorbed VPN, hotspot, proxy and
# per-connection details. By the end it pointed at an application whose Users,
# Sharing, Printers, Online Accounts, Privacy, Region, Colour and Network panels
# all have Panama pages: a generic front door to a settings app you no longer
# need is a habit rather than a boundary. gnome-handoff-contract holds the same
# door shut from the other side, by naming `system` in its OWNED map.
rg -Fq 'SystemSettings.openGnomePanel("system")' "$settings_dir/HealthPage.qml" \
|| fail 'Fedora ownership boundary does not open GNOME Settings'
&& fail 'the "Open GNOME Settings" umbrella button is back, on a page whose panels Panama owns'
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
@@ -138,8 +143,13 @@ rg -Fq 'Health.saveReport(' "$settings_dir/HealthPage.qml" \
# Exact authored handoffs are asserted above. Also prove every panel named by
# this boundary is accepted by SystemSettings, so a typo cannot ship a dead
# button even if its copy still looks correct.
# The card that used to be headed "Fedora system settings" is now headed by the
# one thing left inside it. Screen time is a real boundary: GNOME's wellbeing
# panel does something Panama does not, and that button genuinely works.
rg -Fq 'title: "Digital wellbeing"' "$settings_dir/HealthPage.qml" \
|| fail 'the wellbeing handoff card is gone'
rg -Fq 'title: "Fedora system settings"' "$settings_dir/HealthPage.qml" \
|| fail 'the Fedora ownership boundary card is gone'
&& fail 'the card is headed "Fedora system settings" again, for a single wellbeing button'
allowed="$(rg -o '"[a-z-]+"' "$repo_dir/config/dot/quickshell/services/SystemSettings.qml" \
| sed -n '/"\(applications\|background\|bluetooth\|color\|display\|keyboard\|mouse\|multitasking\|network\|notifications\|online-accounts\|power\|printers\|privacy\|search\|sharing\|sound\|system\|universal-access\|wacom\|wellbeing\|wifi\|wwan\)"/p' \
+196
View File
@@ -87,6 +87,202 @@ rg -Fq 'Restore every shipped shortcut' "$page" \
rg -Fq 'Object.keys(Keybinds.overrides).length' "$page" \
|| fail 'the restore-all row no longer counts what it would put back'
# ── Shortcuts the user invented ──────────────────────────────────────────────
#
# Overrides move a shipped bind and can only ever carry a chord, which is what
# the checks above are about. Custom shortcuts are the harder case: the stored
# entry has to describe an ACTION, and the file it is stored in is one the user
# can open in a text editor.
#
# It stays non-executable, and hypr/actions.lua is the whole reason. A stored
# entry is { chord, kind, target, label }: `kind` is an enum with three
# members, and `target` either names a key of a whitelist table whose values
# are command strings written in Lua by a human, or -- for an application -- is
# an identifier restricted to a character class containing no shell
# metacharacter, quoted as a single argv element for the launch-or-focus path.
#
# There is no third path. Nothing stored anywhere contributes a character to a
# command string. These pin that, because it is the property that makes the
# whole feature safe rather than a config-file injection with a settings page.
actions="$repo_dir/config/dot/hypr/actions.lua"
keybinds="$repo_dir/config/dot/hypr/keybinds.lua"
input_lua="$repo_dir/config/dot/hypr/input.lua"
[[ -r "$actions" ]] || fail "cannot read $actions -- the named-action resolver is gone"
# One resolver, required by both the things that resolve names.
grep -Fq 'require("actions")' "$keybinds" \
|| fail 'keybinds.lua does not use the named-action resolver'
grep -Fq 'require("actions")' "$input_lua" \
|| fail 'input.lua does not use the named-action resolver, so gestures resolve names some other way'
# The target character class. Written as a Lua pattern, so `-` is escaped as
# `%-`; the length bound is a separate check because Lua patterns have no {n,m}.
grep -Fq '"^[A-Za-z0-9@._%-]+$"' "$actions" \
|| fail 'the application target pattern is not the safe character class'
grep -Fq '#target <= 128' "$actions" \
|| fail 'the application target has no length bound'
# Every exec string in actions.lua comes from a table literal in actions.lua.
# The one place a stored value reaches a command is the launch-or-focus path,
# and there it is shell_quote()d -- an argument, not a fragment of a command.
python3 - "$actions" <<'PY' || fail 'actions.lua builds a command out of something other than its own whitelist tables'
import re, sys
source = open(sys.argv[1], encoding="utf-8").read()
# Whole-line comments only. A `--` anywhere else in a Lua line may well be
# inside a string -- "--class" is an argument this very file passes -- and
# treating it as a comment blinds the scan to the rest of the line.
lines = ["" if l.lstrip().startswith("--") else l for l in source.splitlines()]
problems = []
# Anything that puts `target` into a string being concatenated. Exactly two
# forms are permitted, both of which make it one quoted argv element rather
# than a fragment of a command; they are matched literally, so any third way of
# reaching a command string is a finding rather than a regex to be outwitted.
PERMITTED = (
'shell_quote("^" .. escape_regex(target) .. "$")',
"shell_quote(target)",
)
for number, line in enumerate(lines, 1):
if "target" not in line:
continue
stripped = line
for permitted in PERMITTED:
stripped = stripped.replace(permitted, "")
if re.search(r"\.\.\s*[A-Za-z_.]*target|target\s*\.\.", stripped):
problems.append(f"line {number}: {line.strip()[:80]}")
# The command strings themselves are literals in the whitelist table.
for match in re.finditer(r"(?<![\w.])command\s*=\s*([^,\n]+)", source):
value = match.group(1).strip()
if not value.startswith('"'):
problems.append(f"a whitelist command is not a literal: {value[:60]}")
# exec_cmd is only ever handed a whitelist command or the launcher command
# assembled from literals above it.
for number, line in enumerate(lines, 1):
m = re.search(r"exec_cmd\(([^)]*)\)", line)
if m and m.group(1).strip() not in ("verb.command", "launch_command"):
problems.append(f"line {number}: exec_cmd takes {m.group(1).strip()[:60]}")
if problems:
print("\n".join(problems), file=sys.stderr)
raise SystemExit(1)
PY
# ── What the Lua actually emits ──────────────────────────────────────────────
#
# The static read above says the code is shaped right. This runs it, with a
# stubbed `hl` and a synthetic settings file, so the validation is exercised
# rather than trusted -- no compositor, no real preferences.
if command -v lua >/dev/null 2>&1; then
lua_work="$(mktemp -d /tmp/panama-custom-binds.XXXXXX)"
mkdir -p "$lua_work/config/panama" "$lua_work/state"
# Every bind keybinds.lua emits, as "<chord>\t<description>\t<action>".
emit_binds() {
printf '%s' "$1" >"$lua_work/config/panama/settings.json"
XDG_CONFIG_HOME="$lua_work/config" XDG_STATE_HOME="$lua_work/state" lua -e "
package.path = '$repo_dir/config/dot/hypr/?.lua;' .. package.path
hl = {
config = function() end,
dispatch = function() end,
bind = function(chord, action, opts)
print(chord .. '\t' .. tostring((opts or {}).description) .. '\t' .. tostring(action))
end,
dsp = setmetatable({}, { __index = function(_, name)
local function node(path)
return setmetatable({}, {
__index = function(_, key) return node(path .. '.' .. key) end,
__call = function(_, argument)
if type(argument) == 'string' then
return path .. '(' .. argument .. ')'
end
return path .. '()'
end,
})
end
return node(name)
end }),
}
dofile('$keybinds')
" 2>/dev/null
}
baseline="$(emit_binds '{}' | wc -l)"
(( baseline > 100 )) || fail "the shipped keymap emitted $baseline binds, which cannot be right"
# Two good entries, and seven ways of being wrong: an unknown shell verb, a
# command in the target, a command in an app id, a workspace outside 1..10,
# a kind nobody defined, an empty label, and a chord already taken by a
# shipped bind.
shipped_chord="$(emit_binds '{}' | cut -f1 | grep -Fx 'SUPER + T')"
[[ -n "$shipped_chord" ]] || fail 'could not find a shipped chord to collide with'
custom="$(emit_binds '{"customBinds":[
{"chord":"SUPER + SHIFT + F1","kind":"shell","target":"dnd-toggle","label":"Do Not Disturb"},
{"chord":"SUPER + SHIFT + F2","kind":"app","target":"org.gnome.Nautilus","label":"Files"},
{"chord":"SUPER + SHIFT + F3","kind":"window","target":"workspace:4","label":"Workspace 4"},
{"chord":"SUPER + SHIFT + F4","kind":"shell","target":"reboot","label":"Unknown verb"},
{"chord":"SUPER + SHIFT + F5","kind":"shell","target":"dnd-toggle; reboot","label":"Command"},
{"chord":"SUPER + SHIFT + F6","kind":"app","target":"foo $(reboot)","label":"Command in an id"},
{"chord":"SUPER + SHIFT + F7","kind":"window","target":"workspace:0","label":"No such workspace"},
{"chord":"SUPER + SHIFT + F8","kind":"exec","target":"reboot","label":"Invented kind"},
{"chord":"SUPER + SHIFT + F9","kind":"shell","target":"overview","label":""},
{"chord":"SUPER + T","kind":"shell","target":"lock","label":"Steals the terminal key"}
]}')"
added=$(( $(wc -l <<<"$custom") - baseline ))
(( added == 3 )) || fail "ten custom binds with seven invalid added $added binds, not 3"
for chord in 'SUPER + SHIFT + F1' 'SUPER + SHIFT + F2' 'SUPER + SHIFT + F3'; do
grep -Fq "$chord" <<<"$custom" || fail "the valid custom bind $chord was not emitted"
done
# The shipped key kept its action. A custom bind that collides loses; the
# alternative is two binds on one chord and whichever Hyprland reads last.
terminal_line="$(grep -F "$shipped_chord"$'\t' <<<"$custom" | head -1)"
grep -Fq 'Terminal' <<<"$terminal_line" \
|| fail "a custom bind took over a shipped chord: $terminal_line"
# Every custom bind carries the label as its description, because a bind
# with no description is invisible to the cheatsheet and to the page that
# would let you change it.
while IFS=$'\t' read -r chord description _; do
[[ -n "$description" && "$description" != "nil" ]] \
|| fail "the bind $chord has no description"
done <<<"$custom"
# And the actions are only ever whitelist commands or a quoted launch.
while IFS=$'\t' read -r _ _ action; do
case "$action" in
*reboot*) fail "a stored target reached a command: $action" ;;
esac
done <<<"$custom"
grep -Fq "panama-launch --class '^org\\.gnome\\.Nautilus\$' -- gtk-launch 'org.gnome.Nautilus'" <<<"$custom" \
|| fail "the app target is not passed as a quoted argument to the launch-or-focus path: $(grep -F 'SUPER + SHIFT + F2' <<<"$custom")"
# Chords have a bound, and it is the same one overrides have. A 4 KB
# "chord" is not a chord, it is a way to make hyprctl binds unreadable.
long_chord="$(printf 'A%.0s' $(seq 1 65))"
over="$(emit_binds "{\"customBinds\":[{\"chord\":\"$long_chord\",\"kind\":\"shell\",\"target\":\"lock\",\"label\":\"Long\"}]}" | wc -l)"
(( over == baseline )) || fail 'a chord longer than 64 characters was bound anyway'
# A malformed file costs the customizations and never the keymap.
for broken in '{"customBinds":"nope"}' '{"customBinds":[null]}' '{"customBinds":[{"chord":42}]}'; do
(( "$(emit_binds "$broken" | wc -l)" == baseline )) \
|| fail "a malformed customBinds value changed the shipped keymap: $broken"
done
rm -rf "$lua_work"
else
fail 'lua is not installed, so what a custom shortcut becomes went unchecked'
fi
if [[ "${PANAMA_KEYBINDS_STATIC_ONLY:-0}" == "1" ]]; then
printf 'keybind rebind contract: PASS (static)\n'
exit 0
+249 -2
View File
@@ -71,6 +71,7 @@ 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 ────────────────────────────
#
@@ -134,6 +135,58 @@ 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
@@ -222,6 +275,13 @@ command -v jq >/dev/null 2>&1 || { printf 'network tools contract: SKIP (no jq)\
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"
@@ -280,12 +340,37 @@ detail() {
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,
@@ -446,6 +531,168 @@ 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"
@@ -606,9 +853,9 @@ done
[[ -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
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, autoconnect, MAC, import, hotspot, enterprise, proxy, airplane)\n'
printf 'network tools contract: PASS (details, forget, saved, autoconnect, MAC, metered, static IP, import, hotspot, enterprise, hidden, proxy, airplane)\n'
+70
View File
@@ -43,6 +43,9 @@ find_top() {
# ── An empty query is navigation, not a search ───────────────────────────────
[[ "$(find_top '' | jq -r .count)" == "0" ]] || fail 'an empty query returned results'
# Whitespace is an empty query wearing a hat. Tokenizing it produces no tokens,
# which must mean "no search" rather than "every setting matches nothing".
[[ "$(find_top ' ' | jq -r .count)" == "0" ]] || fail 'a whitespace query returned results'
# ── Real settings are findable by what they are ──────────────────────────────
while IFS='|' read -r query expect_label expect_page; do
@@ -80,6 +83,73 @@ CASES
! rg -Fq 'Startup & Services' "$repo_dir/config/dot/quickshell/services/SettingsSearch.qml" \
|| fail 'search index still uses the retired Startup & Services name'
# ── Words, not one contiguous substring ──────────────────────────────────────
#
# The 21 cases above are all single-token or exactly-adjacent, and they were
# passing before tokenization -- they are here to prove tokenizing did not move
# what was already right. These are the ones that were returning nothing at all:
# every word is present in the index, just not adjacent and not in that order.
#
# "wifi password" is the sharpest: the words live in two different fields of the
# same entry, so no substring of any haystack contains the query as typed.
while IFS='|' read -r query expect_label expect_page; do
result="$(find_top "$query")"
got_label="$(jq -r .top <<<"$result")"
got_page="$(jq -r .topPage <<<"$result")"
[[ "$got_label" == "$expect_label" ]] \
|| fail "searching '$query' put '$got_label' first, expected '$expect_label'"
[[ "$got_page" == "$expect_page" ]] \
|| fail "searching '$query' routes to '$got_page', expected '$expect_page'"
done <<'TOKENS'
log out|Log out|power
metered|Metered connection|connectivity
ethernet|Ethernet|connectivity
gestures|Gestures|mouse
saved networks|Saved networks|connectivity
hidden network|Hidden network|connectivity
static ip|Static IP address|connectivity
color filter|Color filter|accessibility
custom shortcut|Custom shortcut|shortcuts
app rules|App rules|tiling
window rules|Window rules|tiling
do not disturb|Do Not Disturb|notifications
four-finger swipe|Four-finger swipe|mouse
TOKENS
# Every word must match, in any order and in any field. Order was the
# accidental part of substring matching, and it was doing the most damage.
for query in 'wifi password' 'password wifi'; do
[[ "$(find_top "$query" | jq -r .count)" != "0" ]] \
|| fail "searching '$query' found nothing, on a page that shows the Wi-Fi password"
[[ "$(find_top "$query" | jq -r .topPage)" == "connectivity" ]] \
|| fail "searching '$query' did not lead with a network result"
done
# AND, not OR. A query holding a word the index does not have anywhere must
# return nothing, or tokenizing has only made the field louder.
[[ "$(find_top 'wallpaper zzzznotathing' | jq -r .count)" == "0" ]] \
|| fail 'a query containing an unmatchable word still returned results'
# ── A result may name the tab it lives on ────────────────────────────────────
#
# Landing on Appearance's Themes tab after searching "theme editor" is the
# search half-working: the page is right and the thing searched for is behind
# another click. Results that name no section route exactly as before, which is
# every result the schema produces.
[[ "$(find_top 'theme editor' | jq -r .topSection)" == "editor" ]] \
|| fail 'the theme editor result does not name the tab it lives on'
[[ "$(find_top 'video wallpaper' | jq -r .topSection)" == "background" ]] \
|| fail 'the video wallpaper result does not name the tab it lives on'
[[ "$(find_top 'timezone' | jq -r .topSection)" == "" ]] \
|| fail 'a result on a page with no tabs still names a section'
# And the sidebar has to consume it, or the field is decorative.
sidebar="$repo_dir/config/dot/quickshell/modules/settings/SettingsSidebar.qml"
rg -Fq 'ShellState.openSettingsSection' "$sidebar" \
|| fail 'the sidebar never opens a result at its section'
rg -Fq 'root.pageRequested' "$sidebar" \
|| fail 'the sidebar lost the plain page route, which most results still use'
# ── Shortcuts are searchable by what they do ─────────────────────────────────
[[ "$(find_top screenshot | jq -r .topPage)" == "shortcuts" ]] \
|| fail 'searching a shortcut description did not route to the shortcuts page'
+13
View File
@@ -241,6 +241,19 @@ grep -q 'copiedKey' "$page" \
grep -qE 'SshKeys\.refresh\(\)' "$page" \
|| fail 'the page cannot refresh, so a key made in a terminal never appears'
# Known hosts are folded at the house cap. A machine in daily use accumulates
# dozens of these, and an unbounded Repeater makes the last card taller than the
# rest of the page put together -- the keys and the agent, which are what
# somebody came here for, end up above the fold of a list nobody reads.
grep -q 'shownHosts' "$page" \
|| fail 'the known-hosts list is not capped, so it grows without limit'
grep -qE 'hostCap: 6' "$page" \
|| fail 'the known-hosts cap is not the house cap of 6'
grep -q 'hiddenHostCount' "$page" \
|| fail 'nothing counts the folded hosts, so the fold row cannot say how many'
grep -qE 'model: root\.shownHosts' "$page" \
|| fail 'the hosts Repeater still walks the whole list, so the cap is decorative'
# ══ The hermetic half ═══════════════════════════════════════════════════════
#
# Everything above reads. Everything below writes -- into a scratch home, with