mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-06 07:15:09 +00:00
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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
Reference in New Issue
Block a user