fix(backup): restore over existing files on hosts without os.chown (#592)

_copy_file() replaces each restored file and then carries the previous
owner across with os.chown. On Windows os.chown does not exist and
st_uid/st_gid are 0 rather than absent, so the ownership branch always
ran and raised AttributeError. That is not an OSError, so it escaped
every per-section handler in restore_backup(): a restore over any
existing config aborted at config.json and restored nothing.

Skip the ownership step where os.chown is missing, as
auto_update_setup.py already does. No change on POSIX.

test_restore_over_a_file_the_user_cannot_write simulates root-owned
files with chmod 0o444; on Windows that sets the read-only attribute,
which blocks any rename over the file, so it is skipped there. The
modes the app writes (0o644/0o640/0o600) replace fine on Windows.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-17 09:26:21 -04:00
committed by GitHub
co-authored by Claude Opus 5
parent 7ae614aa35
commit 1d51efe4c7
3 changed files with 68 additions and 1 deletions
+6
View File
@@ -134,6 +134,12 @@ Core:
from every config load and could not `import web_interface.app` at all. from every config load and could not `import web_interface.app` at all.
`ensure_shared_group_ownership()` now returns immediately when `os.geteuid` `ensure_shared_group_ownership()` now returns immediately when `os.geteuid`
or `os.chown` is missing. No behaviour change on the Pi. or `os.chown` is missing. No behaviour change on the Pi.
- Restoring a backup on Windows no longer fails over files that already exist.
The restore carries each replaced file's owner across with `os.chown`, which
does not exist on Windows; the `AttributeError` escaped the per-file error
handling, so the restore stopped at `config.json` with nothing restored. The
ownership step is now skipped where `os.chown` is missing. No behaviour
change on the Pi.
## 3.4.0 ## 3.4.0
+4 -1
View File
@@ -530,12 +530,15 @@ def _copy_file(src: Path, dst: Path) -> None:
os.chmod(tmp_path, existing_mode) os.chmod(tmp_path, existing_mode)
else: else:
shutil.copymode(src, tmp_path) shutil.copymode(src, tmp_path)
if existing_owner is not None: if existing_owner is not None and hasattr(os, 'chown'):
# Replacing a file creates a new inode owned by whoever is running, # Replacing a file creates a new inode owned by whoever is running,
# which would silently move a root-owned config to the web user. # which would silently move a root-owned config to the web user.
# Carry the previous owner across when the OS permits it — only # Carry the previous owner across when the OS permits it — only
# root can hand a file to another user, so this is best-effort and # root can hand a file to another user, so this is best-effort and
# a plain restore as the web user simply keeps its own ownership. # a plain restore as the web user simply keeps its own ownership.
# os.chown does not exist on Windows (where st_uid/st_gid are just
# 0); looking it up there raises AttributeError, which no caller
# catches, so every restore over an existing file aborted.
try: try:
os.chown(tmp_path, existing_owner[0], existing_owner[1]) os.chown(tmp_path, existing_owner[0], existing_owner[1])
except (OSError, PermissionError): except (OSError, PermissionError):
+58
View File
@@ -3,9 +3,12 @@
from __future__ import annotations from __future__ import annotations
import json import json
import os
import stat import stat
import sys
import zipfile import zipfile
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock
import pytest import pytest
@@ -300,6 +303,11 @@ def test_restore_rejects_malicious_zip(empty_project: Path, tmp_path: Path) -> N
assert any("unsafe" in e.lower() for e in result.errors) assert any("unsafe" in e.lower() for e in result.errors)
@pytest.mark.skipif(
sys.platform == "win32",
reason="simulates root-owned POSIX files with chmod 0o444, which on Windows sets the "
"read-only attribute, and Windows refuses to rename over a read-only file",
)
def test_restore_over_a_file_the_user_cannot_write( def test_restore_over_a_file_the_user_cannot_write(
project: Path, empty_project: Path, tmp_path: Path project: Path, empty_project: Path, tmp_path: Path
) -> None: ) -> None:
@@ -334,3 +342,53 @@ def test_restore_over_a_file_the_user_cannot_write(
# The destination's mode is preserved rather than widened to the umask. # The destination's mode is preserved rather than widened to the umask.
assert stat.S_IMODE((empty_project / "config" / "config_secrets.json").stat().st_mode) == 0o444 assert stat.S_IMODE((empty_project / "config" / "config_secrets.json").stat().st_mode) == 0o444
def _existing_config(empty_project: Path) -> None:
(empty_project / "config").mkdir(parents=True, exist_ok=True)
for name in ("config.json", "config_secrets.json", "wifi_config.json", "ytm_auth.json"):
(empty_project / "config" / name).write_text("{}", encoding="utf-8")
def test_restore_over_existing_files_without_os_chown(
project: Path, empty_project: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Restore must work where the OS has no file ownership API (Windows).
Replacing a file tries to carry its previous owner across with os.chown.
That name does not exist on Windows, and the AttributeError is not an
OSError, so it escaped every per-section handler: restoring over any
existing config aborted the whole restore and left the old files in place.
"""
zip_path = create_backup(project, output_dir=tmp_path / "exports")
_existing_config(empty_project)
monkeypatch.delattr(os, "chown", raising=False)
result = restore_backup(zip_path, empty_project, RestoreOptions())
assert result.success, result.errors
for section in ("config", "secrets", "wifi", "ytm_auth"):
assert section in result.restored, f"{section} not restored: {result.errors}"
restored = json.loads((empty_project / "config" / "config.json").read_text())
assert restored["my-plugin"]["favorites"] == ["A", "B"]
def test_restore_still_carries_the_previous_owner_across(
project: Path, empty_project: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Where os.chown exists, the replaced file keeps the old file's owner."""
zip_path = create_backup(project, output_dir=tmp_path / "exports")
_existing_config(empty_project)
target = empty_project / "config" / "config.json"
old = target.stat()
chown = MagicMock()
monkeypatch.setattr(os, "chown", chown, raising=False)
result = restore_backup(zip_path, empty_project, RestoreOptions(
restore_secrets=False, restore_wifi=False,
restore_fonts=False, restore_plugin_uploads=False, reinstall_plugins=False,
))
assert result.success, result.errors
owners = {(c.args[1], c.args[2]) for c in chown.call_args_list}
assert owners == {(old.st_uid, old.st_gid)}