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:
+312
@@ -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:]))
|
||||
Reference in New Issue
Block a user