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
This commit is contained in:
Gabriel Brown
2026-08-20 11:02:42 -04:00
parent 52e2a83a78
commit de45f205ad
4 changed files with 630 additions and 0 deletions
+312
View File
@@ -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:]))