#!/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 a temporary config home. The real settings store is read # for the export and never written. 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)"; } # ── 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" "${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json" <<'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" 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 ─────────────────────────────────── python3 - "$work/config/panama/settings.json" <<'PY' import json, sys store = json.load(open(sys.argv[1])) store["aSettingTheBundleNeverMentions"] = "kept" json.dump(store, open(sys.argv[1], "w")) PY "$helper" import "$bundle" >/dev/null || fail 'second import failed' python3 - "$work/config/panama/settings.json" <<'PY' || fail 'import replaced the store instead of merging into it' import json, sys store = json.load(open(sys.argv[1])) if store.get("aSettingTheBundleNeverMentions") != "kept": raise SystemExit('a 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'