Files
Panama/tests/quickshell/settings-sync-contract

271 lines
12 KiB
Bash
Executable File

#!/usr/bin/env bash
# Carrying settings to another machine.
#
# The rules:
#
# 1. Export is an allow-list read from the preference schema, not a deny-list
# of things to strip. A key added later that happens to hold a token must
# not be able to leak into a file somebody emails to themselves. Being
# wrong this way loses a setting; being wrong the other way publishes a
# secret.
# 2. What describes the machine stays on the machine. The display arrangement
# is keyed by output names that mean nothing elsewhere.
# 3. Every value is validated again on arrival, per key, with a reason. A file
# from an older Panama is a normal thing to have, and refusing it wholesale
# because one key changed shape would make the feature useless exactly when
# it is most wanted.
# 4. Validation covers every type the schema actually uses. It did not: "real"
# was spelled "float" and enums were assumed to be words, so 36 settings --
# including every numeric enum -- were accepted unchecked, and numeric
# enums were then refused outright once that was noticed.
# 5. Import is a merge. Settings the file does not mention are left alone.
# 6. The preview says what would change, in a shape a diff list can render.
# "12 settings would change" is a number, not an answer; the page now
# shows the rows, so `changes` carries {key, from, to} with both sides
# already turned into text. Doing that stringification in the helper
# rather than in QML is what makes the cap enforceable: a value long
# enough to be something other than a setting is truncated once, here,
# instead of being handed whole to a Text element and to anybody reading
# over a shoulder.
#
# Runs entirely against temporary config homes: one explicit source fixture and
# one isolated import destination. The real settings store is never consulted.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
helper="$repo_dir/config/dot/quickshell/scripts/panama-settings-sync"
schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml"
fail() {
printf 'settings sync contract: %s\n' "$1" >&2
exit 1
}
[[ -x "$helper" ]] || fail 'panama-settings-sync is not executable'
[[ -r "$schema" ]] || fail 'the preference schema is missing'
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
bundle="$work/export.json"
field() { python3 -c "import json,sys; print(json.load(sys.stdin)$1)"; }
export XDG_CONFIG_HOME="$work/source-config"
source_settings="$XDG_CONFIG_HOME/panama/settings.json"
mkdir -p "$(dirname "$source_settings")"
python3 - "$source_settings" <<'PY'
import json, sys
# A representative source store: every value that should travel is valid for
# its schema type, while the machine-only and unknown values prove the allow
# list does not export whatever else happens to be present.
fixture = {
"blurEnabled": False, # boolean
"gapsIn": 7, # integer
"vrrPolicy": 2, # numeric enum
"weatherLocation": "Fixture Harbor", # string
"displays": {"DP-9": "source-only"},
"lastPage": "appearance",
"schemaVersion": 1,
"someFutureToken": "Bearer fixture-secret",
}
with open(sys.argv[1], "w", encoding="utf-8") as target:
json.dump(fixture, target)
PY
# ── 1 & 2. Export carries taste, not hardware ───────────────────────────────
"$helper" export "$bundle" >"$work/export-result.json" || fail 'export failed'
reason="$(field "['error']" <"$work/export-result.json")"
[[ -z "$reason" ]] || fail "export reported: $reason"
[[ "$(stat -c '%a' "$bundle")" == "600" ]] \
|| fail 'the export is readable by other accounts'
python3 - "$bundle" "$schema" "$source_settings" <<'PY' || fail 'the export carried the wrong things, in one direction or the other'
import json, re, sys
bundle = json.load(open(sys.argv[1]))
schema = open(sys.argv[2]).read()
settings = bundle["settings"]
if not settings:
raise SystemExit('the export carried nothing at all')
declared = set(re.findall(r'key:\s*"([A-Za-z0-9_]+)"', schema))
for key in settings:
if key not in declared:
raise SystemExit(f'{key} is not declared in the schema but was exported')
for forbidden in ("displays", "lastPage", "schemaVersion"):
if forbidden in settings:
raise SystemExit(f'{forbidden} describes this machine and must not travel')
# Nothing credential-shaped, whatever the schema says about it.
raw = json.dumps(settings)
for marker in ("BEGIN ", "PRIVATE KEY", "Bearer "):
if marker in raw:
raise SystemExit(f'the export contains {marker!r}')
for key, value in settings.items():
if isinstance(value, str) and len(value) > 300:
raise SystemExit(f'{key} is long enough to be something other than a setting')
# The export must carry what it should, not merely refrain from carrying what it
# should not. Checking only the latter passes trivially when a whole class of
# setting is silently dropped -- which is exactly what happened: numeric enums
# were unreadable, so they never reached the bundle and every "did it arrive"
# check was satisfied by their absence.
present = set(settings)
current = json.load(open(sys.argv[3])) if len(sys.argv) > 3 else {}
for key, value in current.items():
if key in ("displays", "lastPage", "schemaVersion"):
continue
if key in declared and key not in present:
raise SystemExit(f'{key} is set on this machine and declared, but was not carried')
PY
# ── 3, 4. Arrival is validated per key, and the types are all covered ───────
export XDG_CONFIG_HOME="$work/config"
destination_settings="$XDG_CONFIG_HOME/panama/settings.json"
mkdir -p "$(dirname "$destination_settings")"
python3 - "$destination_settings" <<'PY'
import json, sys
# This valid preference is deliberately absent from the source fixture and
# therefore from the bundle. Import must merge around it rather than replace it.
with open(sys.argv[1], "w", encoding="utf-8") as target:
json.dump({"borderSize": 4}, target)
PY
python3 - "$bundle" "$work/tampered.json" <<'PY'
import json, sys
bundle = json.load(open(sys.argv[1]))
bundle["settings"].update({
"gapsIn": 9999, # above the schema maximum
"colorScheme": "chartreuse", # not one of a word enum's choices
"vrrPolicy": 47, # not one of a NUMERIC enum's choices
"blurEnabled": "yes please", # wrong type entirely
"displays": {"DP-9": "elsewhere"}, # machine-specific, injected
"someFutureToken": "sk-abcdef123456", # a key this version does not know
})
json.dump(bundle, open(sys.argv[2], "w"))
PY
"$helper" preview "$work/tampered.json" >"$work/preview.json" || fail 'preview failed'
python3 - "$work/preview.json" <<'PY' || fail 'a bad value was not refused with its reason'
import json, sys
plan = json.load(open(sys.argv[1]))
skipped = {entry["key"]: entry["reason"] for entry in plan["skipped"]}
for key in ("gapsIn", "colorScheme", "vrrPolicy", "blurEnabled", "displays", "someFutureToken"):
if key not in skipped:
raise SystemExit(f'{key} was accepted and should not have been')
if not skipped[key].strip():
raise SystemExit(f'{key} was skipped without saying why')
changed = {entry["key"] for entry in plan["changes"]}
for key in ("gapsIn", "colorScheme", "vrrPolicy", "blurEnabled", "displays", "someFutureToken"):
if key in changed:
raise SystemExit(f'{key} was refused and queued for application anyway')
PY
# ── 6. The preview renders as a diff, and cannot render a secret whole ──────
python3 - "$bundle" "$work/oversized.json" <<'PY'
import json, sys
bundle = json.load(open(sys.argv[1]))
# A real, free-text, non-path string setting, so this exercises a value that
# genuinely travels rather than one the validator would refuse for its own
# reasons. 4000 characters is not a location; it is somebody's paste buffer.
bundle["settings"]["weatherLocation"] = "Bearer sk-fixture-secret-" + ("x" * 4000)
json.dump(bundle, open(sys.argv[2], "w"))
PY
"$helper" preview "$work/oversized.json" >"$work/oversized-preview.json" \
|| fail 'preview failed on a bundle carrying an oversized value'
python3 - "$work/preview.json" "$work/oversized-preview.json" <<'PY' || fail 'the preview does not describe changes in a shape a diff list can render safely'
import json
import sys
CAP = 200
for path in sys.argv[1:]:
plan = json.load(open(path))
if "changes" not in plan:
raise SystemExit('the preview does not say what would change')
if "changeCount" not in plan:
raise SystemExit('the preview lists changes without saying how many there are, '
'so a capped list reads as the whole truth')
count = plan["changeCount"]
if not isinstance(count, int) or count < 0:
raise SystemExit('changeCount is not a count')
if count != len(plan["apply"]):
raise SystemExit(f'changeCount says {count} but {len(plan["apply"])} would be applied')
if len(plan["changes"]) > count:
raise SystemExit('the rendered list is longer than the number of changes')
for entry in plan["changes"]:
if set(entry) != {"key", "from", "to"}:
raise SystemExit(f'a change carries {sorted(entry)}, expected key/from/to')
for side in ("key", "from", "to"):
if not isinstance(entry[side], str):
raise SystemExit(f'{entry["key"]}.{side} is {type(entry[side]).__name__}, '
'not text a row can render')
if len(entry[side]) > CAP:
raise SystemExit(f'{entry["key"]}.{side} is {len(entry[side])} characters; '
'an uncapped value reaches the screen whole')
oversized = json.load(open(sys.argv[2]))
rendered = json.dumps(oversized["changes"])
if "x" * (CAP + 1) in rendered:
raise SystemExit('an oversized value was reproduced in full in the change list')
PY
# A clean bundle must arrive intact. Refusing valid settings is the failure this
# contract exists to catch as much as accepting invalid ones -- fixing the enum
# check the first time turned every numeric enum into a rejection.
"$helper" import "$bundle" >"$work/import.json" || fail 'import failed'
python3 - "$bundle" "$work/config/panama/settings.json" <<'PY' || fail 'the round trip lost or changed a setting'
import json, sys
sent = json.load(open(sys.argv[1]))["settings"]
landed = json.load(open(sys.argv[2]))
missing = [k for k in sent if k not in landed]
if missing:
raise SystemExit(f'{len(missing)} settings did not arrive, starting with {missing[:3]}')
wrong = [k for k, v in sent.items() if landed[k] != v]
if wrong:
raise SystemExit(f'{len(wrong)} arrived with a different value, starting with {wrong[:3]}')
PY
# ── 5. Import merges rather than replaces ───────────────────────────────────
"$helper" import "$bundle" >/dev/null || fail 'second import failed'
python3 - "$bundle" "$destination_settings" <<'PY' || fail 'import replaced the store instead of merging into it'
import json, sys
bundle = json.load(open(sys.argv[1]))["settings"]
store = json.load(open(sys.argv[2]))
if "borderSize" in bundle:
raise SystemExit('the merge sentinel unexpectedly appeared in the import bundle')
if store.get("borderSize") != 4:
raise SystemExit('a schema-valid setting the bundle did not mention was removed')
PY
applied="$("$helper" import "$bundle" | field "['applied']")"
[[ "$applied" == "0" ]] \
|| fail "importing the same bundle twice applied $applied changes the second time"
# ── Refusals ────────────────────────────────────────────────────────────────
printf 'not json at all\n' >"$work/junk.json"
reason="$("$helper" import "$work/junk.json" | field "['error']")"
[[ "$reason" == *"not a settings export"* ]] \
|| fail "a file that is not an export was not refused with a reason (got: $reason)"
reason="$("$helper" import "$work/absent.json" | field "['error']")"
[[ "$reason" == *"does not exist"* ]] \
|| fail "a missing file was not refused with a reason (got: $reason)"
printf 'settings sync contract: ok\n'