241 lines
11 KiB
Bash
Executable File
241 lines
11 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 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'
|