mirror of
https://github.com/home-assistant/supervisor.git
synced 2025-07-08 17:56:33 +00:00
Always validate Backup before restoring (#5632)
* Validate Backup always before restoring Since #5519 we check the encryption password early in restore case. This has the side effect that we check the file existance early too. However, in the non-encryption case, the file is not checked early. This PR changes the behavior to always validate the backup file before restoring, ensuring both encryption and non-encryption cases are handled consistently. In particular, the last case of test_restore_immediate_errors actually validates that behavior. That test should actually have failed so far. But it seems that because we validate the backup shortly after freeze anyways, the exception still got raised early enough. A simply `await asyncio.sleep(10)` right after the freeze makes the test case fail. With this change, the test works consistently. * Address pylint * Fix backup_manager tests * Drop warning message
This commit is contained in:
parent
9b2dbd634d
commit
4c108eea64
@ -366,14 +366,14 @@ class Backup(JobGroup):
|
|||||||
backend=default_backend(),
|
backend=default_backend(),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def validate_password(self, location: str | None) -> bool:
|
async def validate_backup(self, location: str | None) -> None:
|
||||||
"""Validate backup password.
|
"""Validate backup.
|
||||||
|
|
||||||
Returns false only when the password is known to be wrong.
|
Checks if we can access the backup file and decrypt if necessary.
|
||||||
"""
|
"""
|
||||||
backup_file: Path = self.all_locations[location][ATTR_PATH]
|
backup_file: Path = self.all_locations[location][ATTR_PATH]
|
||||||
|
|
||||||
def _validate_file() -> bool:
|
def _validate_file() -> None:
|
||||||
ending = f".tar{'.gz' if self.compressed else ''}"
|
ending = f".tar{'.gz' if self.compressed else ''}"
|
||||||
|
|
||||||
with tarfile.open(backup_file, "r:") as backup:
|
with tarfile.open(backup_file, "r:") as backup:
|
||||||
@ -386,8 +386,8 @@ class Backup(JobGroup):
|
|||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
if not test_tar_name:
|
if not test_tar_name:
|
||||||
_LOGGER.warning("No tar file found to validate password with")
|
# From Supervisor perspective, a metadata only backup only is valid.
|
||||||
return True
|
return
|
||||||
|
|
||||||
test_tar_file = backup.extractfile(test_tar_name)
|
test_tar_file = backup.extractfile(test_tar_name)
|
||||||
try:
|
try:
|
||||||
@ -399,16 +399,14 @@ class Backup(JobGroup):
|
|||||||
fileobj=test_tar_file,
|
fileobj=test_tar_file,
|
||||||
):
|
):
|
||||||
# If we can read the tar file, the password is correct
|
# If we can read the tar file, the password is correct
|
||||||
return True
|
return
|
||||||
except tarfile.ReadError:
|
except tarfile.ReadError as ex:
|
||||||
_LOGGER.debug("Invalid password")
|
raise BackupInvalidError(
|
||||||
return False
|
f"Invalid password for backup {backup.slug}", _LOGGER.error
|
||||||
except Exception: # pylint: disable=broad-exception-caught
|
) from ex
|
||||||
_LOGGER.exception("Unexpected error validating password")
|
|
||||||
return True
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return await self.sys_run_in_executor(_validate_file)
|
await self.sys_run_in_executor(_validate_file)
|
||||||
except FileNotFoundError as err:
|
except FileNotFoundError as err:
|
||||||
self.sys_create_task(self.sys_backups.reload(location))
|
self.sys_create_task(self.sys_backups.reload(location))
|
||||||
raise BackupFileNotFoundError(
|
raise BackupFileNotFoundError(
|
||||||
|
@ -717,7 +717,7 @@ class BackupManager(FileConfiguration, JobGroup):
|
|||||||
_job_override__cleanup=False
|
_job_override__cleanup=False
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _set_location_password(
|
async def _validate_backup_location(
|
||||||
self,
|
self,
|
||||||
backup: Backup,
|
backup: Backup,
|
||||||
password: str | None = None,
|
password: str | None = None,
|
||||||
@ -732,13 +732,11 @@ class BackupManager(FileConfiguration, JobGroup):
|
|||||||
location = location if location != DEFAULT else backup.location
|
location = location if location != DEFAULT else backup.location
|
||||||
if backup.all_locations[location][ATTR_PROTECTED]:
|
if backup.all_locations[location][ATTR_PROTECTED]:
|
||||||
backup.set_password(password)
|
backup.set_password(password)
|
||||||
if not await backup.validate_password(location):
|
|
||||||
raise BackupInvalidError(
|
|
||||||
f"Invalid password for backup {backup.slug}", _LOGGER.error
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
backup.set_password(None)
|
backup.set_password(None)
|
||||||
|
|
||||||
|
await backup.validate_backup(location)
|
||||||
|
|
||||||
@Job(
|
@Job(
|
||||||
name=JOB_FULL_RESTORE,
|
name=JOB_FULL_RESTORE,
|
||||||
conditions=[
|
conditions=[
|
||||||
@ -767,7 +765,7 @@ class BackupManager(FileConfiguration, JobGroup):
|
|||||||
f"{backup.slug} is only a partial backup!", _LOGGER.error
|
f"{backup.slug} is only a partial backup!", _LOGGER.error
|
||||||
)
|
)
|
||||||
|
|
||||||
await self._set_location_password(backup, password, location)
|
await self._validate_backup_location(backup, password, location)
|
||||||
|
|
||||||
if backup.supervisor_version > self.sys_supervisor.version:
|
if backup.supervisor_version > self.sys_supervisor.version:
|
||||||
raise BackupInvalidError(
|
raise BackupInvalidError(
|
||||||
@ -832,7 +830,7 @@ class BackupManager(FileConfiguration, JobGroup):
|
|||||||
folder_list.remove(FOLDER_HOMEASSISTANT)
|
folder_list.remove(FOLDER_HOMEASSISTANT)
|
||||||
homeassistant = True
|
homeassistant = True
|
||||||
|
|
||||||
await self._set_location_password(backup, password, location)
|
await self._validate_backup_location(backup, password, location)
|
||||||
|
|
||||||
if backup.homeassistant is None and homeassistant:
|
if backup.homeassistant is None and homeassistant:
|
||||||
raise BackupInvalidError(
|
raise BackupInvalidError(
|
||||||
|
@ -16,7 +16,11 @@ from supervisor.backups.backup import Backup
|
|||||||
from supervisor.const import CoreState
|
from supervisor.const import CoreState
|
||||||
from supervisor.coresys import CoreSys
|
from supervisor.coresys import CoreSys
|
||||||
from supervisor.docker.manager import DockerAPI
|
from supervisor.docker.manager import DockerAPI
|
||||||
from supervisor.exceptions import AddonsError, HomeAssistantBackupError
|
from supervisor.exceptions import (
|
||||||
|
AddonsError,
|
||||||
|
BackupInvalidError,
|
||||||
|
HomeAssistantBackupError,
|
||||||
|
)
|
||||||
from supervisor.homeassistant.core import HomeAssistantCore
|
from supervisor.homeassistant.core import HomeAssistantCore
|
||||||
from supervisor.homeassistant.module import HomeAssistant
|
from supervisor.homeassistant.module import HomeAssistant
|
||||||
from supervisor.homeassistant.websocket import HomeAssistantWebSocket
|
from supervisor.homeassistant.websocket import HomeAssistantWebSocket
|
||||||
@ -466,6 +470,7 @@ async def test_restore_immediate_errors(
|
|||||||
assert "only a partial backup" in (await resp.json())["message"]
|
assert "only a partial backup" in (await resp.json())["message"]
|
||||||
|
|
||||||
with (
|
with (
|
||||||
|
patch.object(Backup, "validate_backup"),
|
||||||
patch.object(
|
patch.object(
|
||||||
Backup,
|
Backup,
|
||||||
"supervisor_version",
|
"supervisor_version",
|
||||||
@ -488,7 +493,11 @@ async def test_restore_immediate_errors(
|
|||||||
patch.object(
|
patch.object(
|
||||||
Backup, "all_locations", new={None: {"path": None, "protected": True}}
|
Backup, "all_locations", new={None: {"path": None, "protected": True}}
|
||||||
),
|
),
|
||||||
patch.object(Backup, "validate_password", return_value=False),
|
patch.object(
|
||||||
|
Backup,
|
||||||
|
"validate_backup",
|
||||||
|
side_effect=BackupInvalidError("Invalid password"),
|
||||||
|
),
|
||||||
):
|
):
|
||||||
resp = await api_client.post(
|
resp = await api_client.post(
|
||||||
f"/backups/{mock_partial_backup.slug}/restore/partial",
|
f"/backups/{mock_partial_backup.slug}/restore/partial",
|
||||||
@ -497,7 +506,10 @@ async def test_restore_immediate_errors(
|
|||||||
assert resp.status == 400
|
assert resp.status == 400
|
||||||
assert "Invalid password" in (await resp.json())["message"]
|
assert "Invalid password" in (await resp.json())["message"]
|
||||||
|
|
||||||
with patch.object(Backup, "homeassistant", new=PropertyMock(return_value=None)):
|
with (
|
||||||
|
patch.object(Backup, "validate_backup"),
|
||||||
|
patch.object(Backup, "homeassistant", new=PropertyMock(return_value=None)),
|
||||||
|
):
|
||||||
resp = await api_client.post(
|
resp = await api_client.post(
|
||||||
f"/backups/{mock_partial_backup.slug}/restore/partial",
|
f"/backups/{mock_partial_backup.slug}/restore/partial",
|
||||||
json={"background": True, "homeassistant": True},
|
json={"background": True, "homeassistant": True},
|
||||||
@ -944,7 +956,7 @@ async def test_restore_backup_from_location(
|
|||||||
body = await resp.json()
|
body = await resp.json()
|
||||||
assert (
|
assert (
|
||||||
body["message"]
|
body["message"]
|
||||||
== f"Cannot open backup at {backup_local_path.as_posix()}, file does not exist!"
|
== f"Cannot validate backup at {backup_local_path.as_posix()}, file does not exist!"
|
||||||
)
|
)
|
||||||
|
|
||||||
resp = await api_client.post(
|
resp = await api_client.post(
|
||||||
|
@ -42,6 +42,7 @@ def partial_backup_mock(backup_mock):
|
|||||||
backup_instance.supervisor_version = "9999.09.9.dev9999"
|
backup_instance.supervisor_version = "9999.09.9.dev9999"
|
||||||
backup_instance.location = None
|
backup_instance.location = None
|
||||||
backup_instance.all_locations = {None: {"protected": False}}
|
backup_instance.all_locations = {None: {"protected": False}}
|
||||||
|
backup_instance.validate_backup = AsyncMock()
|
||||||
yield backup_mock
|
yield backup_mock
|
||||||
|
|
||||||
|
|
||||||
@ -55,6 +56,7 @@ def full_backup_mock(backup_mock):
|
|||||||
backup_instance.supervisor_version = "9999.09.9.dev9999"
|
backup_instance.supervisor_version = "9999.09.9.dev9999"
|
||||||
backup_instance.location = None
|
backup_instance.location = None
|
||||||
backup_instance.all_locations = {None: {"protected": False}}
|
backup_instance.all_locations = {None: {"protected": False}}
|
||||||
|
backup_instance.validate_backup = AsyncMock()
|
||||||
yield backup_mock
|
yield backup_mock
|
||||||
|
|
||||||
|
|
||||||
|
@ -11,7 +11,7 @@ import pytest
|
|||||||
from supervisor.backups.backup import Backup
|
from supervisor.backups.backup import Backup
|
||||||
from supervisor.backups.const import BackupType
|
from supervisor.backups.const import BackupType
|
||||||
from supervisor.coresys import CoreSys
|
from supervisor.coresys import CoreSys
|
||||||
from supervisor.exceptions import BackupFileNotFoundError
|
from supervisor.exceptions import BackupFileNotFoundError, BackupInvalidError
|
||||||
|
|
||||||
from tests.common import get_fixture_path
|
from tests.common import get_fixture_path
|
||||||
|
|
||||||
@ -79,22 +79,21 @@ async def test_consolidate(
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"tarfile_side_effect, securetar_side_effect, expected_result, expect_exception",
|
"tarfile_side_effect, securetar_side_effect, expected_exception",
|
||||||
[
|
[
|
||||||
(None, None, True, False), # Successful validation
|
(None, None, None), # Successful validation
|
||||||
(FileNotFoundError, None, None, True), # File not found
|
(FileNotFoundError, None, BackupFileNotFoundError), # File not found
|
||||||
(None, tarfile.ReadError, False, False), # Invalid password
|
(None, tarfile.ReadError, BackupInvalidError), # Invalid password
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_validate_password(
|
async def test_validate_backup(
|
||||||
coresys: CoreSys,
|
coresys: CoreSys,
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
tarfile_side_effect,
|
tarfile_side_effect,
|
||||||
securetar_side_effect,
|
securetar_side_effect,
|
||||||
expected_result,
|
expected_exception,
|
||||||
expect_exception,
|
|
||||||
):
|
):
|
||||||
"""Parameterized test for validate_password."""
|
"""Parameterized test for validate_backup."""
|
||||||
enc_tar = Path(copy(get_fixture_path("backup_example_enc.tar"), tmp_path))
|
enc_tar = Path(copy(get_fixture_path("backup_example_enc.tar"), tmp_path))
|
||||||
enc_backup = Backup(coresys, enc_tar, "test", None)
|
enc_backup = Backup(coresys, enc_tar, "test", None)
|
||||||
await enc_backup.load()
|
await enc_backup.load()
|
||||||
@ -120,9 +119,8 @@ async def test_validate_password(
|
|||||||
MagicMock(side_effect=securetar_side_effect),
|
MagicMock(side_effect=securetar_side_effect),
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
if expect_exception:
|
if expected_exception:
|
||||||
with pytest.raises(BackupFileNotFoundError):
|
with pytest.raises(expected_exception):
|
||||||
await enc_backup.validate_password(None)
|
await enc_backup.validate_backup(None)
|
||||||
else:
|
else:
|
||||||
result = await enc_backup.validate_password(None)
|
await enc_backup.validate_backup(None)
|
||||||
assert result == expected_result
|
|
||||||
|
@ -344,7 +344,7 @@ async def test_fail_invalid_full_backup(
|
|||||||
|
|
||||||
backup_instance = full_backup_mock.return_value
|
backup_instance = full_backup_mock.return_value
|
||||||
backup_instance.all_locations[None]["protected"] = True
|
backup_instance.all_locations[None]["protected"] = True
|
||||||
backup_instance.validate_password = AsyncMock(return_value=False)
|
backup_instance.validate_backup.side_effect = BackupInvalidError()
|
||||||
|
|
||||||
with pytest.raises(BackupInvalidError):
|
with pytest.raises(BackupInvalidError):
|
||||||
await manager.do_restore_full(backup_instance)
|
await manager.do_restore_full(backup_instance)
|
||||||
@ -373,7 +373,7 @@ async def test_fail_invalid_partial_backup(
|
|||||||
|
|
||||||
backup_instance = partial_backup_mock.return_value
|
backup_instance = partial_backup_mock.return_value
|
||||||
backup_instance.all_locations[None]["protected"] = True
|
backup_instance.all_locations[None]["protected"] = True
|
||||||
backup_instance.validate_password = AsyncMock(return_value=False)
|
backup_instance.validate_backup.side_effect = BackupInvalidError()
|
||||||
|
|
||||||
with pytest.raises(BackupInvalidError):
|
with pytest.raises(BackupInvalidError):
|
||||||
await manager.do_restore_partial(backup_instance)
|
await manager.do_restore_partial(backup_instance)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user