Compare commits

..
Author SHA1 Message Date
ChuckBuildsandClaude Opus 5 ab8ff0e7e9 fix(backup): preserve the destination's mode and owner when merging
Self-review catch. The merge path wrote the combined config with write_text
and renamed it into place, bypassing _copy_file -- which preserves the
destination's mode and owner deliberately, because these files are installed
root-owned while the web interface that runs a restore is not root, and
because widening them to the umask default is exactly the wrong thing to do
to a file that sits next to secrets.

Measured: a config.json at 0600 came back 0664.

The merged result is now written to a scratch file and handed to _copy_file,
which does the atomic replace with the preservation it was written for. The
scratch file is removed in a finally block.

Two tests added: the mode survives a merge, and no scratch file is left
behind. Reverting to the bare rename fails the first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW
2026-08-21 10:29:16 -04:00
ChuckBuildsandClaude Opus 5 b7a7e26bfe fix(backup): stop a restore repointing the device at another panel
restore_backup copied the backup's config.json over the local one wholesale,
display.hardware included. That block is not configuration in the portable
sense -- it describes the panel physically wired to this machine: cols, rows,
chain_length, hardware_mapping, panel_type, multiplexing, and the refresh-rate
cap.

So restoring a backup taken on a 512x64 rig onto a 128x32 one repointed the
smaller panel at the larger one's geometry. Nothing on screen explains that;
the display simply stops being right, and the setting that broke it is one the
user never touched.

display.hardware is now held back by default and the rest of config.json is
restored as before. RestoreOptions.restore_hardware opts into the old
behaviour for the case it actually suits -- restoring onto identical hardware,
or onto a replacement for the machine the backup came from. When the two
differ, the kept geometry and the discarded one are both logged, so the choice
is visible afterwards.

A device with no local display.hardware takes the backup's, since there is
nothing to preserve. An unparseable file on either side falls back to the
plain copy: a restore must not fail because of this merge.

Reverting the guard fails the test that the local panel survives. 40 backup
and restore tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW
2026-08-20 22:08:26 -04:00
4 changed files with 185 additions and 49 deletions
+75 -1
View File
@@ -111,6 +111,12 @@ class RestoreOptions:
"""Which sections of a backup should be restored.""" """Which sections of a backup should be restored."""
restore_config: bool = True restore_config: bool = True
#: Whether to take the backup's display.hardware block as well.
#: Off by default: that block describes the panel physically wired to
#: *this* device -- its size, chain length, mapping, multiplexing and
#: refresh cap. A backup carries the panel of the machine it was taken
#: on, and restoring one onto a different rig drives the wrong geometry.
restore_hardware: bool = False
restore_secrets: bool = True restore_secrets: bool = True
restore_wifi: bool = True restore_wifi: bool = True
restore_fonts: bool = True restore_fonts: bool = True
@@ -549,6 +555,72 @@ def _copy_file(src: Path, dst: Path) -> None:
raise raise
_HARDWARE_PATH = ("display", "hardware")
def _restore_config_preserving_hardware(src: Path, dst: Path, keep_hardware: bool) -> None:
"""Copy a backed-up config.json, optionally keeping the local panel block.
display.hardware describes the panel physically attached to this device:
cols, rows, chain_length, hardware_mapping, panel_type, multiplexing and
the refresh-rate cap. None of that travels with a configuration -- it is a
property of the machine. Restoring a backup taken on a 512x64 rig onto a
128x32 one used to overwrite the smaller panel's geometry with the larger
one's, which is not a setting the user can see going wrong; the display
simply stops being right.
Falls back to a plain copy when either file cannot be parsed, so a restore
never fails because of this.
"""
if not keep_hardware:
_copy_file(src, dst)
return
try:
incoming = json.loads(src.read_text(encoding="utf-8"))
local = json.loads(dst.read_text(encoding="utf-8")) if dst.exists() else {}
except (OSError, ValueError) as exc:
logger.warning(
"[Backup] Could not merge local panel config (%s); restoring the "
"backup's config.json as-is", exc)
_copy_file(src, dst)
return
section, key = _HARDWARE_PATH
local_hw = (local.get(section) or {}).get(key)
if not isinstance(local_hw, dict) or not local_hw:
_copy_file(src, dst)
return
if not isinstance(incoming.get(section), dict):
incoming[section] = {}
incoming_hw = incoming[section].get(key)
incoming[section][key] = local_hw
if isinstance(incoming_hw, dict) and incoming_hw != local_hw:
logger.info(
"[Backup] Kept this device's display.hardware; the backup's panel "
"was %sx%s chain %s, this one is %sx%s chain %s",
incoming_hw.get("cols"), incoming_hw.get("rows"),
incoming_hw.get("chain_length"),
local_hw.get("cols"), local_hw.get("rows"),
local_hw.get("chain_length"))
# Write the merged result to a scratch file and hand it to _copy_file
# rather than renaming it into place here. _copy_file preserves the
# destination's mode and owner on purpose -- these config files are
# installed root-owned while the web interface that runs a restore is not
# root -- and a bare write would have replaced a root-owned config.json
# with one owned by the web user at whatever the umask allows.
scratch = dst.with_suffix(dst.suffix + ".merged-tmp")
try:
scratch.write_text(json.dumps(incoming, indent=2) + "\n", encoding="utf-8")
_copy_file(scratch, dst)
finally:
try:
scratch.unlink()
except OSError:
pass
def restore_backup( def restore_backup(
zip_path: Path, zip_path: Path,
project_root: Path, project_root: Path,
@@ -584,7 +656,9 @@ def restore_backup(
# Main config. # Main config.
if options.restore_config and (tmp_dir / _CONFIG_REL).exists(): if options.restore_config and (tmp_dir / _CONFIG_REL).exists():
try: try:
_copy_file(tmp_dir / _CONFIG_REL, project_root / _CONFIG_REL) _restore_config_preserving_hardware(
tmp_dir / _CONFIG_REL, project_root / _CONFIG_REL,
keep_hardware=not options.restore_hardware)
result.restored.append("config") result.restored.append("config")
except OSError as e: except OSError as e:
logger.error("[Backup] Failed to restore config.json: %s", e, exc_info=True) logger.error("[Backup] Failed to restore config.json: %s", e, exc_info=True)
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""A restore must not repoint this device at another machine's panel.
display.hardware describes the panel physically wired to this device -- cols,
rows, chain_length, hardware_mapping, panel_type, multiplexing, the refresh
cap. A backup carries the panel of the machine it was taken on. Restoring a
512x64 rig's backup onto a 128x32 one used to overwrite the smaller panel's
geometry with the larger one's, and nothing on screen explains why: the
display just stops being right.
That is not hypothetical. It happened, and the rig it happened to had to be
reflashed.
"""
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.backup_manager import _restore_config_preserving_hardware # noqa: E402
BIG = {"display": {"hardware": {"cols": 128, "rows": 64, "chain_length": 4,
"hardware_mapping": "adafruit-hat-pwm"},
"runtime": {"gpio_slowdown": 4}},
"timezone": "America/New_York", "some-plugin": {"enabled": True}}
SMALL = {"display": {"hardware": {"cols": 64, "rows": 32, "chain_length": 2,
"hardware_mapping": "regular"},
"runtime": {"gpio_slowdown": 2}},
"timezone": "UTC"}
def _run(tmp, keep):
src = tmp / "backup_config.json"; src.write_text(json.dumps(BIG))
dst = tmp / "config.json"; dst.write_text(json.dumps(SMALL))
_restore_config_preserving_hardware(src, dst, keep_hardware=keep)
return json.loads(dst.read_text())
def test_local_panel_survives(tmp_path):
out = _run(tmp_path, keep=True)
hw = out["display"]["hardware"]
assert (hw["cols"], hw["rows"], hw["chain_length"]) == (64, 32, 2), (
"the restore repointed this device at the backup's panel")
assert hw["hardware_mapping"] == "regular", "panel wiring came from the backup"
def test_everything_else_is_restored(tmp_path):
out = _run(tmp_path, keep=True)
assert out["timezone"] == "America/New_York", "config was not restored"
assert out["some-plugin"] == {"enabled": True}, "plugin config was not restored"
assert out["display"]["runtime"] == {"gpio_slowdown": 4}, (
"only display.hardware should be held back")
def test_opting_in_takes_the_backups_panel(tmp_path):
out = _run(tmp_path, keep=False)
hw = out["display"]["hardware"]
assert (hw["cols"], hw["rows"], hw["chain_length"]) == (128, 64, 4)
def test_a_device_with_no_local_hardware_takes_the_backups(tmp_path):
src = tmp_path / "b.json"; src.write_text(json.dumps(BIG))
dst = tmp_path / "c.json"; dst.write_text(json.dumps({"timezone": "UTC"}))
_restore_config_preserving_hardware(src, dst, keep_hardware=True)
out = json.loads(dst.read_text())
assert out["display"]["hardware"]["cols"] == 128, (
"nothing local to preserve, so the backup's panel should be used")
def test_unparseable_local_config_still_restores(tmp_path):
src = tmp_path / "b.json"; src.write_text(json.dumps(BIG))
dst = tmp_path / "c.json"; dst.write_text("{ not json")
_restore_config_preserving_hardware(src, dst, keep_hardware=True)
assert json.loads(dst.read_text())["timezone"] == "America/New_York", (
"a restore must never fail because of this merge")
def test_the_destination_mode_is_preserved(tmp_path):
"""config.json is installed with a deliberate mode; a merge must not widen it.
_copy_file preserves the destination's mode and owner on purpose -- these
files are root-owned while the web interface running the restore is not.
Writing the merged result directly would have replaced that with whatever
the umask allowed.
"""
import os
import stat as statmod
src = tmp_path / "b.json"; src.write_text(json.dumps(BIG))
dst = tmp_path / "c.json"; dst.write_text(json.dumps(SMALL))
os.chmod(dst, 0o600)
_restore_config_preserving_hardware(src, dst, keep_hardware=True)
mode = statmod.S_IMODE(os.stat(dst).st_mode)
assert mode == 0o600, f"restore widened config.json from 0600 to {oct(mode)}"
assert json.loads(dst.read_text())["display"]["hardware"]["cols"] == 64
def test_no_scratch_file_is_left_behind(tmp_path):
src = tmp_path / "b.json"; src.write_text(json.dumps(BIG))
dst = tmp_path / "c.json"; dst.write_text(json.dumps(SMALL))
_restore_config_preserving_hardware(src, dst, keep_hardware=True)
leftovers = [p.name for p in tmp_path.iterdir() if "tmp" in p.name]
assert not leftovers, f"scratch files left in place: {leftovers}"
@@ -194,46 +194,15 @@ class TestSavePluginConfig:
def test_secret_count_message_counts_top_level_keys(self, env): def test_secret_count_message_counts_top_level_keys(self, env):
# Pinned: the "(N secret field(s))" message counts TOP-LEVEL keys of # Pinned: the "(N secret field(s))" message counts TOP-LEVEL keys of
# the separated secrets dict. Here that is 1: the posted accounts # the separated secrets dict. Here that is 2: the posted accounts
# array, whose item tokens all count as ONE key. # array (all its item tokens count as ONE key) plus the schema's
# # api_key default ("") that merge_with_defaults adds before
# It was 2 before blank secrets were dropped, the second being the # separation.
# schema's api_key default (""), which merge_with_defaults adds to
# every save. Counting it was the visible edge of a real bug: that
# injected blank was merged over the stored api_key, so saving any
# unrelated field destroyed the credential. See
# test_an_unrelated_edit_does_not_erase_a_stored_secret.
resp = self._save(env, { resp = self._save(env, {
"accounts": [{"name": "a", "token": "t"}], "accounts": [{"name": "a", "token": "t"}],
}) })
message = resp.get_json()["message"] message = resp.get_json()["message"]
assert "(1 secret field(s) saved to config_secrets.json)" in message assert "(2 secret field(s) saved to config_secrets.json)" in message
def test_an_unrelated_edit_does_not_erase_a_stored_secret(self, env):
"""Editing one field must not wipe the plugin's API key.
The config form renders secrets masked, so the browser posts them
back blank; merge_with_defaults injects a blank api_key even when
the client omits it entirely. Either way a "" reached the secrets
file and deep_merge wrote it over the stored credential.
"""
assert self._save(env, {"api_key": "REAL-KEY-0123456789",
"city": "Austin"}).status_code == 200
assert _on_disk(env.secrets_file)[PLUGIN_ID]["api_key"] == \
"REAL-KEY-0123456789"
# the user changes the city; the masked api_key rides along blank
assert self._save(env, {"api_key": "", "city": "Dallas"}).status_code == 200
assert _on_disk(env.secrets_file)[PLUGIN_ID]["api_key"] == \
"REAL-KEY-0123456789", "an unrelated edit destroyed the API key"
assert env.fresh_load()[PLUGIN_ID]["city"] == "Dallas"
def test_a_secret_can_still_be_changed(self, env):
"""Dropping blanks must not stop a real new value from being saved."""
self._save(env, {"api_key": "first-key"})
self._save(env, {"api_key": "second-key"})
assert _on_disk(env.secrets_file)[PLUGIN_ID]["api_key"] == "second-key"
def test_resave_replaces_stored_secrets_list_wholesale(self, env): def test_resave_replaces_stored_secrets_list_wholesale(self, env):
# Characterized: api_v3's deep_merge intentionally replaces lists, # Characterized: api_v3's deep_merge intentionally replaces lists,
+1 -12
View File
@@ -21,8 +21,7 @@ logger = logging.getLogger(__name__)
# Import new infrastructure # Import new infrastructure
from src.web_interface.api_helpers import success_response, error_response, validate_request_json from src.web_interface.api_helpers import success_response, error_response, validate_request_json
from src.web_interface.errors import ErrorCode from src.web_interface.errors import ErrorCode
from src.web_interface.secret_helpers import (find_secret_fields, remove_empty_secrets, from src.web_interface.secret_helpers import find_secret_fields, separate_secrets
separate_secrets)
from src.web_interface.error_handler import describe_exception, redact_text from src.web_interface.error_handler import describe_exception, redact_text
from src.plugin_system.operation_types import OperationType from src.plugin_system.operation_types import OperationType
from src.web_interface.validators import ( from src.web_interface.validators import (
@@ -1217,11 +1216,6 @@ def save_main_config():
# Separate secrets from regular config (same logic as save_plugin_config) # Separate secrets from regular config (same logic as save_plugin_config)
regular_config, secrets_config = separate_secrets(plugin_config, secret_fields) regular_config, secrets_config = separate_secrets(plugin_config, secret_fields)
# The config form renders secrets masked, so every save posts
# them back blank. Without this the blank is merged over the
# stored value and the credential is destroyed by the act of
# changing an unrelated setting. A blank means "unchanged".
secrets_config = remove_empty_secrets(secrets_config)
# PRE-PROCESSING: Preserve 'enabled' state if not in regular_config # PRE-PROCESSING: Preserve 'enabled' state if not in regular_config
# This prevents overwriting the enabled state when saving config from a form that doesn't include the toggle # This prevents overwriting the enabled state when saving config from a form that doesn't include the toggle
@@ -5605,11 +5599,6 @@ def save_plugin_config():
# Separate secrets from regular config (handles nested configs and # Separate secrets from regular config (handles nested configs and
# array-item secrets — see src/web_interface/secret_helpers.py) # array-item secrets — see src/web_interface/secret_helpers.py)
regular_config, secrets_config = separate_secrets(plugin_config, secret_fields) regular_config, secrets_config = separate_secrets(plugin_config, secret_fields)
# The config form renders secrets masked, so every save posts
# them back blank. Without this the blank is merged over the
# stored value and the credential is destroyed by the act of
# changing an unrelated setting. A blank means "unchanged".
secrets_config = remove_empty_secrets(secrets_config)
# Get current configs # Get current configs
current_config = api_v3.config_manager.load_config() current_config = api_v3.config_manager.load_config()