diff --git a/src/backup_manager.py b/src/backup_manager.py index 9c9d15fe..491b1274 100644 --- a/src/backup_manager.py +++ b/src/backup_manager.py @@ -12,7 +12,6 @@ used from scripts. from __future__ import annotations -import io import json import logging import os @@ -189,7 +188,7 @@ def list_installed_plugins(project_root: Path) -> List[Dict[str, Any]]: try: with state_file.open("r", encoding="utf-8") as f: state = json.load(f) - raw_plugins = state.get("plugins", {}) if isinstance(state, dict) else {} + raw_plugins = state.get("states", {}) if isinstance(state, dict) else {} if isinstance(raw_plugins, dict): for plugin_id, info in raw_plugins.items(): if not isinstance(info, dict): @@ -290,9 +289,9 @@ def create_backup( contents: List[str] = [] - # Build bundle in memory first, then atomically write to final path. - buffer = io.BytesIO() - with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as zf: + # Stream directly to a temp file so we never hold the whole ZIP in memory. + tmp_path = zip_path.with_suffix(".zip.tmp") + with zipfile.ZipFile(tmp_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: # Config files. if (project_root / _CONFIG_REL).exists(): zf.write(project_root / _CONFIG_REL, _CONFIG_REL.as_posix()) @@ -333,9 +332,6 @@ def create_backup( manifest = _build_manifest(contents, project_root) zf.writestr(MANIFEST_NAME, json.dumps(manifest, indent=2)) - # Write atomically. - tmp_path = zip_path.with_suffix(".zip.tmp") - tmp_path.write_bytes(buffer.getvalue()) os.replace(tmp_path, zip_path) logger.info("Created backup %s (%d bytes)", zip_path, zip_path.stat().st_size) return zip_path @@ -429,7 +425,10 @@ def validate_backup(zip_path: Path) -> Tuple[bool, str, Dict[str, Any]]: detected.append("wifi") if any(n.startswith(_FONTS_REL.as_posix() + "/") for n in names): detected.append("fonts") - if any(n.startswith(_PLUGIN_UPLOADS_REL.as_posix() + "/") for n in names): + if any( + n.startswith(_PLUGIN_UPLOADS_REL.as_posix() + "/") and "/uploads/" in n + for n in names + ): detected.append("plugin_uploads") plugins: List[Dict[str, Any]] = [] @@ -569,6 +568,9 @@ def restore_backup( for name in files: src = Path(root) / name rel = src.relative_to(tmp_dir) + if "/uploads/" not in rel.as_posix(): + result.errors.append(f"Rejected unexpected plugin path: {rel}") + continue try: _copy_file(src, project_root / rel) count += 1 diff --git a/test/test_backup_manager.py b/test/test_backup_manager.py index e8287b14..2d36602f 100644 --- a/test/test_backup_manager.py +++ b/test/test_backup_manager.py @@ -12,6 +12,7 @@ import pytest from src import backup_manager from src.backup_manager import ( BUNDLED_FONTS, + SCHEMA_VERSION, RestoreOptions, create_backup, list_installed_plugins, @@ -66,10 +67,11 @@ def _make_project(root: Path) -> Path: (root / "data" / "plugin_state.json").write_text( json.dumps( { - "plugins": { + "version": 1, + "states": { "my-plugin": {"version": "1.2.3", "enabled": True}, "other-plugin": {"version": "0.1.0", "enabled": False}, - } + }, } ), encoding="utf-8", @@ -204,7 +206,7 @@ def test_validate_backup_bad_schema_version(tmp_path: Path) -> None: def test_validate_backup_rejects_zip_traversal(tmp_path: Path) -> None: zip_path = tmp_path / "malicious.zip" with zipfile.ZipFile(zip_path, "w") as zf: - zf.writestr("manifest.json", json.dumps({"schema_version": 1, "contents": []})) + zf.writestr("manifest.json", json.dumps({"schema_version": SCHEMA_VERSION, "contents": []})) zf.writestr("../../etc/passwd", "x") ok, err, _ = validate_backup(zip_path) assert not ok @@ -214,7 +216,7 @@ def test_validate_backup_rejects_zip_traversal(tmp_path: Path) -> None: def test_validate_backup_not_a_zip(tmp_path: Path) -> None: p = tmp_path / "nope.zip" p.write_text("hello", encoding="utf-8") - ok, err, _ = validate_backup(p) + ok, _err, _ = validate_backup(p) assert not ok @@ -275,7 +277,7 @@ def test_restore_honors_options(project: Path, empty_project: Path, tmp_path: Pa def test_restore_rejects_malicious_zip(empty_project: Path, tmp_path: Path) -> None: zip_path = tmp_path / "bad.zip" with zipfile.ZipFile(zip_path, "w") as zf: - zf.writestr("manifest.json", json.dumps({"schema_version": 1, "contents": []})) + zf.writestr("manifest.json", json.dumps({"schema_version": SCHEMA_VERSION, "contents": []})) zf.writestr("../escape.txt", "x") result = restore_backup(zip_path, empty_project, RestoreOptions()) # validate_backup catches it before extraction. diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index 5de684bc..513159c5 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -1232,8 +1232,20 @@ def _save_uploaded_backup_to_temp() -> Tuple[Optional[Path], Optional[Tuple[Resp fd, tmp_name = _tempfile.mkstemp(prefix='ledmatrix_upload_', suffix='.zip') os.close(fd) tmp_path = Path(tmp_name) + max_bytes = 200 * 1024 * 1024 try: - upload.save(str(tmp_path)) + written = 0 + with open(tmp_path, 'wb') as fh: + while True: + chunk = upload.stream.read(65536) + if not chunk: + break + written += len(chunk) + if written > max_bytes: + fh.close() + tmp_path.unlink(missing_ok=True) + return None, (jsonify({'status': 'error', 'message': 'Backup file exceeds 200 MB limit'}), 413) + fh.write(chunk) except Exception: tmp_path.unlink(missing_ok=True) logger.exception("[Backup] Failed to save uploaded backup") @@ -1286,12 +1298,12 @@ def backup_restore(): return jsonify({'status': 'error', 'message': 'Invalid options JSON'}), 400 opts = backup_manager.RestoreOptions( - restore_config=bool(opts_dict.get('restore_config', True)), - restore_secrets=bool(opts_dict.get('restore_secrets', True)), - restore_wifi=bool(opts_dict.get('restore_wifi', True)), - restore_fonts=bool(opts_dict.get('restore_fonts', True)), - restore_plugin_uploads=bool(opts_dict.get('restore_plugin_uploads', True)), - reinstall_plugins=bool(opts_dict.get('reinstall_plugins', True)), + restore_config=_coerce_to_bool(opts_dict.get('restore_config', True)), + restore_secrets=_coerce_to_bool(opts_dict.get('restore_secrets', True)), + restore_wifi=_coerce_to_bool(opts_dict.get('restore_wifi', True)), + restore_fonts=_coerce_to_bool(opts_dict.get('restore_fonts', True)), + restore_plugin_uploads=_coerce_to_bool(opts_dict.get('restore_plugin_uploads', True)), + reinstall_plugins=_coerce_to_bool(opts_dict.get('reinstall_plugins', True)), ) # Snapshot current config through the atomic manager so the pre-restore @@ -1299,7 +1311,9 @@ def backup_restore(): if api_v3.config_manager and opts.restore_config: try: current = api_v3.config_manager.load_config() - _save_config_atomic(api_v3.config_manager, current, create_backup=True) + snapshot_ok, snapshot_err = _save_config_atomic(api_v3.config_manager, current, create_backup=True) + if not snapshot_ok: + logger.warning("[Backup] Pre-restore snapshot failed: %s (continuing)", snapshot_err) except Exception: logger.warning("[Backup] Pre-restore snapshot failed (continuing)", exc_info=True) @@ -1337,7 +1351,7 @@ def backup_restore(): from web_interface.cache import delete_cached delete_cached('fonts_catalog') except Exception: - pass + logger.warning("[Backup] Failed to clear font cache", exc_info=True) # Reload config_manager state so the UI picks up the new values # without a full service restart. @@ -1348,7 +1362,7 @@ def backup_restore(): try: api_v3.config_manager.load_config() except Exception: - pass + logger.warning("[Backup] Could not reload config after restore", exc_info=True) except Exception: logger.warning("[Backup] Could not reload config after restore", exc_info=True) diff --git a/web_interface/templates/v3/partials/backup_restore.html b/web_interface/templates/v3/partials/backup_restore.html index 2da52c61..00e43009 100644 --- a/web_interface/templates/v3/partials/backup_restore.html +++ b/web_interface/templates/v3/partials/backup_restore.html @@ -117,6 +117,8 @@