From de45f205adaa816886f7b242df1c9c488235e51e Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Thu, 20 Aug 2026 11:02:42 -0400 Subject: [PATCH] Carry settings between machines by allow-list, not by stripping panama-settings-backup already snapshots this machine so it can be put back exactly as it was, arrangement and all. This is the other thing: an export meant to travel, carrying the preferences that describe taste rather than hardware. The export is an allow-list read from the preference schema rather than a deny-list of things to remove. A key added later that happens to hold a token cannot leak into a file somebody emails to themselves; being wrong in this direction loses a setting, being wrong the other way publishes a secret. It earned that immediately -- this machine's store holds an orphaned shadowOffset from a setting that no longer exists anywhere in the source, and it was left behind without anyone having to know about it. Three settings stay: the display arrangement, which is keyed by output names that mean nothing elsewhere; the last page opened, which is session noise; and schemaVersion, which belongs to the store rather than to a person. Import is a merge, so settings a file does not mention are left alone, and it is idempotent. Two bugs made and caught here, in opposite directions. Validation missed 36 settings because "real" was spelled "float" and enums fell through entirely, so an out-of-range or nonsense value would have been written straight into the store. Correcting that then broke numeric enums -- vrrPolicy is an enum of 0..3 and the options were read with a regex that only matched quoted values, so those settings had no known choices, were declared unverifiable and were refused: valid settings dropped silently in transit. The contract could not see the second one. It checked only that bad values are refused, and when numeric enums were unreadable they never reached the bundle at all, so every "did it arrive" assertion was satisfied by their absence. It now requires the export to carry what it should as well as withhold what it should not, and was verified to fail in both directions. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L --- .../modules/settings/DesktopPage.qml | 65 ++++ .../quickshell/scripts/panama-settings-sync | 312 ++++++++++++++++++ .../dot/quickshell/services/SettingsSync.qml | 75 +++++ tests/quickshell/settings-sync-contract.sh | 178 ++++++++++ 4 files changed, 630 insertions(+) create mode 100755 config/dot/quickshell/scripts/panama-settings-sync create mode 100644 config/dot/quickshell/services/SettingsSync.qml create mode 100755 tests/quickshell/settings-sync-contract.sh diff --git a/config/dot/quickshell/modules/settings/DesktopPage.qml b/config/dot/quickshell/modules/settings/DesktopPage.qml index 0df8125..771531c 100644 --- a/config/dot/quickshell/modules/settings/DesktopPage.qml +++ b/config/dot/quickshell/modules/settings/DesktopPage.qml @@ -123,6 +123,71 @@ SettingsPage { SliderRow { setting: "focusDurationMinutes"; divider: false } } + // Distinct from the snapshots below, which put THIS machine back as it + // was. This carries settings to a different one, and deliberately leaves + // behind anything that describes hardware. + SettingsCard { + title: "Carry settings to another machine" + subtitle: SettingsSync.lastError !== "" + ? SettingsSync.lastError + : "Everything except what describes this machine: the display arrangement stays here." + + ActionRow { + label: "Export" + detail: SettingsSync.lastAction === "export" && SettingsSync.carried > 0 + ? SettingsSync.carried + " settings written to " + SettingsSync.defaultPath + : "Writes " + SettingsSync.defaultPath + action: "Export" + enabled: !SettingsSync.busy + onTriggered: SettingsSync.exportTo(SettingsSync.defaultPath) + } + + ActionRow { + label: "See what an import would change" + detail: SettingsSync.previewed + ? SettingsSync.changes.length + " would change, " + + SettingsSync.skipped.length + " skipped" + : "Reads " + SettingsSync.defaultPath + " without applying anything" + action: "Preview" + enabled: !SettingsSync.busy + onTriggered: SettingsSync.preview(SettingsSync.defaultPath) + } + + // Only offered once a preview has said what it would do. Importing + // settings sight unseen is how somebody ends up wondering why their + // desktop changed. + ActionRow { + visible: SettingsSync.previewed && SettingsSync.changes.length > 0 + label: "Apply those " + SettingsSync.changes.length + " changes" + detail: "Settings the file does not mention are left alone" + action: "Import" + enabled: !SettingsSync.busy + onTriggered: SettingsSync.importFrom(SettingsSync.defaultPath) + } + + Repeater { + model: SettingsSync.previewed ? SettingsSync.skipped : [] + + delegate: TextRow { + required property var modelData + width: parent.width + label: String(modelData.key ?? "") + detail: "Skipped: " + String(modelData.reason ?? "") + value: "" + } + } + + TextRow { + visible: SettingsSync.lastAction === "import" && SettingsSync.lastError === "" + label: SettingsSync.applied === 0 + ? "Nothing needed changing" + : SettingsSync.applied + " settings applied" + detail: "From " + (SettingsSync.exportedFrom || "the export") + value: "" + divider: false + } + } + SettingsCard { title: "Snapshots" subtitle: SettingsBackup.lastError !== "" diff --git a/config/dot/quickshell/scripts/panama-settings-sync b/config/dot/quickshell/scripts/panama-settings-sync new file mode 100755 index 0000000..8123926 --- /dev/null +++ b/config/dot/quickshell/scripts/panama-settings-sync @@ -0,0 +1,312 @@ +#!/usr/bin/env python3 + +"""Carrying Panama's settings to another machine. + +Different from panama-settings-backup, which snapshots this machine so it can be +put back exactly as it was, arrangement and all. This produces something meant +to travel: the preferences that describe taste rather than hardware. + +Two decisions shape the whole thing. + +The export is an ALLOW-LIST taken from the preference schema, not a deny-list of +things to strip. Anything the schema does not declare is dropped, so a key added +later that happens to hold a token cannot leak into a file somebody emails to +themselves. Being wrong in this direction loses a setting; being wrong the other +way publishes a secret. + +The import validates every value against the schema again on arrival and skips +what does not fit, one key at a time, 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. + + panama-settings-sync export PATH + panama-settings-sync preview PATH + panama-settings-sync import PATH +""" + +from __future__ import annotations + +import json +import os +import re +import socket +import sys +import time +from pathlib import Path + +HOME = Path(os.environ.get("HOME", str(Path.home()))) +CONFIG_ROOT = Path(os.environ.get("XDG_CONFIG_HOME", str(HOME / ".config"))) +SETTINGS = CONFIG_ROOT / "panama/settings.json" + +SCHEMA = Path(__file__).resolve().parents[1] / "config" / "PreferenceSchema.qml" + +FORMAT = "panama-settings-sync/1" + +# Settings that describe this machine rather than how it should behave. Held +# separately from the allow-list because each needs a reason, and a reason is +# what stops the list growing by habit. +MACHINE_SPECIFIC = { + # Monitor arrangement, keyed by output names that mean nothing elsewhere. + "displays": "describes this machine's monitors", + # Which settings page was last open. Session noise. + "lastPage": "is where you happened to be looking", + # Governs migration of the store itself; importing one would mislabel it. + "schemaVersion": "belongs to the store, not to you", +} + +# Settings that travel but may not land: an absolute path is only a setting on a +# machine where the file exists. Carried, then checked on arrival. +PATH_VALUED = {"wallpaperPath", "wallpaperSlideshowPaths"} + + +class BoundaryError(RuntimeError): + """A user-visible validation or file failure.""" + + +def schema() -> dict[str, dict]: + """Every declared preference, by key. + + Parsed from the schema rather than kept as a second list here, so a setting + added there is exportable without anyone remembering to update this. + """ + try: + source = SCHEMA.read_text(encoding="utf-8") + except OSError as error: + raise BoundaryError("The preference schema could not be read.") from error + + entries: dict[str, dict] = {} + for chunk in source.split("key: ")[1:]: + head = chunk[:1600] + key = re.match(r'"([A-Za-z0-9_]+)"', chunk) + kind = re.search(r'type:\s*"([a-z]+)"', head) + if not (key and kind): + continue + entry = {"key": key.group(1), "type": kind.group(1)} + for bound in ("min", "max"): + found = re.search(rf"\b{bound}:\s*(-?[0-9.]+)", head) + if found: + entry[bound] = float(found.group(1)) + pattern = re.search(r'pattern:\s*"((?:[^"\\]|\\.)*)"', head) + if pattern: + entry["pattern"] = pattern.group(1).replace("\\\\", "\\") + # Option values are quoted for a word and bare for a number -- vrrPolicy + # is an enum of 0..3. Capturing only the quoted form left numeric enums + # with no choices at all, which then read as unverifiable and were + # refused: a valid setting dropped on the way in. + options = [quoted if quoted else bare for quoted, bare + in re.findall(r'value:\s*(?:"([^"]*)"|(-?[0-9]+(?:\.[0-9]+)?))', head)] + if options: + entry["options"] = options + entries[entry["key"]] = entry + if not entries: + raise BoundaryError("The preference schema yielded no settings.") + return entries + + +def stored() -> dict: + if not SETTINGS.is_file(): + return {} + try: + value = json.loads(SETTINGS.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise BoundaryError("This machine's settings could not be read.") from error + return value if isinstance(value, dict) else {} + + +def fits(entry: dict, value) -> str: + """"" if the value is usable for this setting, else why it is not.""" + kind = entry["type"] + if kind == "bool": + return "" if isinstance(value, bool) else "is not a yes or no" + if kind == "enum": + # Compared as text because an enum is words in some settings and numbers + # in others, and JSON gives back 3 where the schema wrote 3. + if isinstance(value, bool) or not isinstance(value, (str, int, float)): + return "is not one of this setting's choices" + value = str(value) + choices = entry.get("options") or [] + # A schema entry whose choices could not be read is not grounds to + # accept anything: an unrecognised value would be written straight into + # the store and break whatever reads it. + if not choices: + return "cannot be checked against this setting's choices" + return "" if value in choices else "is not one of this setting's choices" + # "real" is what the schema calls a float. Spelling it "float" here meant + # fifteen settings were accepted without their range being checked at all. + if kind in ("int", "real", "float"): + if isinstance(value, bool) or not isinstance(value, (int, float)): + return "is not a number" + if "min" in entry and value < entry["min"]: + return f"is below the minimum of {entry['min']:g}" + if "max" in entry and value > entry["max"]: + return f"is above the maximum of {entry['max']:g}" + return "" + if kind == "string": + if not isinstance(value, str): + return "is not text" + if "options" in entry and entry["options"] and value not in entry["options"]: + return "is not one of the choices this setting allows" + if "pattern" in entry: + try: + if not re.match(entry["pattern"], value): + return "does not match the form this setting takes" + except re.error: + return "" + return "" + if kind == "json": + return "" if isinstance(value, (list, dict)) else "is not a list or an object" + return "" + + +def exportable() -> tuple[dict, list[dict]]: + known = schema() + current = stored() + + carried: dict = {} + left: list[dict] = [] + for key, value in sorted(current.items()): + if key in MACHINE_SPECIFIC: + left.append({"key": key, "reason": MACHINE_SPECIFIC[key]}) + continue + if key not in known: + left.append({"key": key, "reason": "is not a setting this version declares"}) + continue + problem = fits(known[key], value) + if problem: + left.append({"key": key, "reason": "holds a value that " + problem}) + continue + carried[key] = value + return carried, left + + +def export(path: str) -> dict: + carried, left = exportable() + bundle = { + "format": FORMAT, + "exportedAt": int(time.time()), + "exportedFrom": socket.gethostname(), + "settings": carried, + } + target = Path(path).expanduser() + try: + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(json.dumps(bundle, indent=2, sort_keys=True) + "\n", + encoding="utf-8") + # Readable only by its owner: it is not secret, but it is a description + # of somebody's machine and there is no reason to hand it around. + target.chmod(0o600) + except OSError as error: + raise BoundaryError("That file could not be written.") from error + + return {"path": str(target), "carried": len(carried), "left": left} + + +def read_bundle(path: str) -> dict: + source = Path(path).expanduser() + try: + bundle = json.loads(source.read_text(encoding="utf-8")) + except FileNotFoundError as error: + raise BoundaryError("That file does not exist.") from error + except (OSError, json.JSONDecodeError) as error: + raise BoundaryError("That file is not a settings export.") from error + if not isinstance(bundle, dict) or not str(bundle.get("format", "")).startswith("panama-settings-sync/"): + raise BoundaryError("That file is not a settings export.") + if not isinstance(bundle.get("settings"), dict): + raise BoundaryError("That export contains no settings.") + return bundle + + +def plan(path: str) -> dict: + """What an import would do, without doing any of it.""" + bundle = read_bundle(path) + known = schema() + current = stored() + + apply: dict = {} + changes: list[dict] = [] + skipped: list[dict] = [] + + for key, value in sorted(bundle["settings"].items()): + if key in MACHINE_SPECIFIC: + skipped.append({"key": key, "reason": MACHINE_SPECIFIC[key]}) + continue + if key not in known: + skipped.append({"key": key, "reason": "is not a setting this version has"}) + continue + problem = fits(known[key], value) + if problem: + skipped.append({"key": key, "reason": "the value " + problem}) + continue + if key in PATH_VALUED: + missing = [p for p in (value if isinstance(value, list) else [value]) + if isinstance(p, str) and p and not Path(p).expanduser().exists()] + if missing: + skipped.append({"key": key, + "reason": "points at a file this machine does not have"}) + continue + if current.get(key) == value: + continue + apply[key] = value + changes.append({"key": key, + "from": current.get(key, None), + "to": value}) + + return { + "path": str(Path(path).expanduser()), + "exportedFrom": str(bundle.get("exportedFrom", "")), + "exportedAt": int(bundle.get("exportedAt", 0)), + "changes": changes, + "skipped": skipped, + "apply": apply, + } + + +def apply_import(path: str) -> dict: + """Merge an export into this machine's settings. + + Written whole through a temporary file and a rename, so a crash midway + leaves the old settings intact rather than half of each. Settings not named + by the export are untouched: this is a merge, not a replacement, because an + export from a machine that never changed a setting should not reset it here. + """ + prepared = plan(path) + if not prepared["apply"]: + return {**prepared, "applied": 0} + + current = stored() + current.update(prepared["apply"]) + + try: + SETTINGS.parent.mkdir(parents=True, exist_ok=True) + temporary = SETTINGS.with_suffix(".sync-tmp") + temporary.write_text(json.dumps(current, indent=2, sort_keys=True) + "\n", + encoding="utf-8") + os.replace(temporary, SETTINGS) + except OSError as error: + raise BoundaryError("This machine's settings could not be written.") from error + + return {**prepared, "applied": len(prepared["apply"])} + + +def main(arguments: list[str]) -> int: + try: + if len(arguments) == 2 and arguments[0] == "export": + result = export(arguments[1]) + elif len(arguments) == 2 and arguments[0] == "preview": + result = plan(arguments[1]) + elif len(arguments) == 2 and arguments[0] == "import": + result = apply_import(arguments[1]) + else: + raise BoundaryError( + "Usage: panama-settings-sync export PATH | preview PATH | import PATH") + except BoundaryError as error: + print(json.dumps({"error": str(error)}, separators=(",", ":"))) + return 0 + + result["error"] = "" + print(json.dumps(result, separators=(",", ":"))) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/config/dot/quickshell/services/SettingsSync.qml b/config/dot/quickshell/services/SettingsSync.qml new file mode 100644 index 0000000..dbfdb46 --- /dev/null +++ b/config/dot/quickshell/services/SettingsSync.qml @@ -0,0 +1,75 @@ +pragma Singleton + +// Carrying settings to another machine. +// +// Distinct from SettingsBackup, which snapshots this machine so it can be put +// back exactly as it was. This produces something meant to travel: the +// preferences that describe taste rather than hardware. +// +// The helper exports by allow-list from the preference schema, so a key added +// later that happens to hold a token cannot leak into a file somebody emails to +// themselves, and validates everything again on arrival. + +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-settings-sync" + + // Where an export lands by default. Somewhere a person will find it again. + readonly property string defaultPath: + (Quickshell.env("HOME") ?? "") + "/panama-settings.json" + + property string lastError: "" + property string lastAction: "" + property int carried: 0 + property int applied: 0 + property var left: [] + property var changes: [] + property var skipped: [] + property string exportedFrom: "" + property bool previewed: false + + readonly property bool busy: worker.running + + function run(action: string, path: string): void { + if (worker.running) + return; + root.lastError = ""; + root.lastAction = action; + worker.command = [root.helperPath, action, path]; + worker.running = true; + } + + function exportTo(path: string): void { root.run("export", path); } + function preview(path: string): void { root.run("preview", path); } + function importFrom(path: string): void { root.run("import", path); } + + function absorb(text: string): void { + try { + const parsed = JSON.parse(text); + root.lastError = String(parsed.error ?? ""); + root.carried = Number(parsed.carried ?? 0); + root.applied = Number(parsed.applied ?? 0); + root.left = Array.isArray(parsed.left) ? parsed.left : []; + root.changes = Array.isArray(parsed.changes) ? parsed.changes : []; + root.skipped = Array.isArray(parsed.skipped) ? parsed.skipped : []; + root.exportedFrom = String(parsed.exportedFrom ?? ""); + root.previewed = root.lastAction === "preview" && root.lastError === ""; + } catch (error) { + root.lastError = "Could not read the result."; + console.warn("SettingsSync: could not parse helper output:", error); + } + } + + Process { + id: worker + stdout: StdioCollector { onStreamFinished: root.absorb(this.text) } + stderr: StdioCollector { + onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim() + } + } +} diff --git a/tests/quickshell/settings-sync-contract.sh b/tests/quickshell/settings-sync-contract.sh new file mode 100755 index 0000000..1069b66 --- /dev/null +++ b/tests/quickshell/settings-sync-contract.sh @@ -0,0 +1,178 @@ +#!/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. +# +# 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 + +# 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'