diff --git a/supervisor/addons/addon.py b/supervisor/addons/addon.py index 79f688dc0..f859717af 100644 --- a/supervisor/addons/addon.py +++ b/supervisor/addons/addon.py @@ -51,7 +51,7 @@ from ..exceptions import ( ) from ..utils.apparmor import adjust_profile from ..utils.json import read_json_file, write_json_file -from ..utils.tar import exclude_filter, secure_path +from ..utils.tar import secure_path, atomic_contents_add from .model import AddonModel, Data from .utils import remove_data from .validate import SCHEMA_ADDON_SNAPSHOT, validate_options @@ -534,10 +534,12 @@ class Addon(AddonModel): async def snapshot(self, tar_file: tarfile.TarFile) -> None: """Snapshot state of an add-on.""" with TemporaryDirectory(dir=self.sys_config.path_tmp) as temp: + temp_path = Path(temp) + # store local image if self.need_build: try: - await self.instance.export_image(Path(temp, "image.tar")) + await self.instance.export_image(temp_path.joinpath("image.tar")) except DockerAPIError: raise AddonsError() from None @@ -550,14 +552,14 @@ class Addon(AddonModel): # Store local configs/state try: - write_json_file(Path(temp, "addon.json"), data) + write_json_file(temp_path.joinpath("addon.json"), data) except JsonFileError: _LOGGER.error("Can't save meta for %s", self.slug) raise AddonsError() from None # Store AppArmor Profile if self.sys_host.apparmor.exists(self.slug): - profile = Path(temp, "apparmor.txt") + profile = temp_path.joinpath("apparmor.txt") try: self.sys_host.apparmor.backup_profile(self.slug, profile) except HostAppArmorError: @@ -569,13 +571,15 @@ class Addon(AddonModel): """Write tar inside loop.""" with tar_file as snapshot: # Snapshot system + snapshot.add(temp, arcname=".") # Snapshot data - snapshot.add( + atomic_contents_add( + snapshot, self.path_data, + excludes=self.snapshot_exclude, arcname="data", - filter=exclude_filter(self.snapshot_exclude), ) try: diff --git a/supervisor/snapshots/snapshot.py b/supervisor/snapshots/snapshot.py index 9774e8e40..e82a597cc 100644 --- a/supervisor/snapshots/snapshot.py +++ b/supervisor/snapshots/snapshot.py @@ -42,7 +42,11 @@ from ..const import ( from ..coresys import CoreSys, CoreSysAttributes from ..exceptions import AddonsError from ..utils.json import write_json_file -from ..utils.tar import SecureTarFile, exclude_filter, secure_path +from ..utils.tar import ( + SecureTarFile, + secure_path, + atomic_contents_add, +) from .utils import key_to_iv, password_for_validating, password_to_key, remove_folder from .validate import ALL_FOLDERS, SCHEMA_SNAPSHOT @@ -370,10 +374,11 @@ class Snapshot(CoreSysAttributes): try: _LOGGER.info("Snapshot folder %s", name) with SecureTarFile(tar_name, "w", key=self._key) as tar_file: - tar_file.add( + atomic_contents_add( + tar_file, origin_dir, + excludes=MAP_FOLDER_EXCLUDE.get(name, []), arcname=".", - filter=exclude_filter(MAP_FOLDER_EXCLUDE.get(name, [])), ) _LOGGER.info("Snapshot folder %s done", name) diff --git a/supervisor/utils/tar.py b/supervisor/utils/tar.py index 712613071..44a96b512 100644 --- a/supervisor/utils/tar.py +++ b/supervisor/utils/tar.py @@ -2,9 +2,9 @@ import hashlib import logging import os -from pathlib import Path +from pathlib import Path, PurePath import tarfile -from typing import IO, Callable, Generator, List, Optional +from typing import IO, Generator, List, Optional from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import padding @@ -134,20 +134,41 @@ def secure_path(tar: tarfile.TarFile) -> Generator[tarfile.TarInfo, None, None]: yield member -def exclude_filter( - exclude_list: List[str], -) -> Callable[[tarfile.TarInfo], Optional[tarfile.TarInfo]]: - """Create callable filter function to check TarInfo for add.""" +def _is_excluded_by_filter(path: PurePath, exclude_list: List[str]) -> bool: + """Filter to filter excludes.""" - def my_filter(tar: tarfile.TarInfo) -> Optional[tarfile.TarInfo]: - """Filter to filter excludes.""" - file_path = Path(tar.name) - for exclude in exclude_list: - if not file_path.match(exclude): - continue - _LOGGER.debug("Ignore %s because of %s", file_path, exclude) - return None + for exclude in exclude_list: + if not path.match(exclude): + continue + _LOGGER.debug("Ignore %s because of %s", path, exclude) + return True - return tar + return False - return my_filter + +def atomic_contents_add( + tar_file: tarfile.TarFile, + origin_path: Path, + excludes: List[str], + arcname: str = ".", +) -> None: + """Append directories and/or files to the TarFile if excludes wont filter.""" + + if _is_excluded_by_filter(origin_path, excludes): + return None + + # Add directory only (recursive=False) to ensure we also archive empty directories + tar_file.add(origin_path.as_posix(), arcname, recursive=False) + + for directory_item in origin_path.iterdir(): + if _is_excluded_by_filter(directory_item, excludes): + continue + + arcpath = PurePath(arcname, directory_item.name).as_posix() + if directory_item.is_dir(): + atomic_contents_add(tar_file, directory_item.as_posix(), excludes, arcpath) + continue + + tar_file.add(directory_item.as_posix(), arcname=arcpath) + + return None diff --git a/tests/utils/test_tarfile.py b/tests/utils/test_tarfile.py index 1e8b5eff3..907282985 100644 --- a/tests/utils/test_tarfile.py +++ b/tests/utils/test_tarfile.py @@ -2,7 +2,8 @@ import attr -from supervisor.utils.tar import exclude_filter, secure_path +from pathlib import PurePath +from supervisor.utils.tar import secure_path, _is_excluded_by_filter @attr.s @@ -33,28 +34,29 @@ def test_not_secure_path(): assert [] == list(secure_path(test_list)) -def test_exclude_filter_good(): +def test_is_excluded_by_filter_good(): """Test exclude filter.""" - filter_funct = exclude_filter(["not/match", "/dev/xy"]) + filter_list = ["not/match", "/dev/xy"] test_list = [ - TarInfo("test.txt"), - TarInfo("data/xy.blob"), - TarInfo("bla/blu/ble"), - TarInfo("data/../xy.blob"), + PurePath("test.txt"), + PurePath("data/xy.blob"), + PurePath("bla/blu/ble"), + PurePath("data/../xy.blob"), ] - assert test_list == [filter_funct(result) for result in test_list] + for path_object in test_list: + assert _is_excluded_by_filter(path_object, filter_list) is False -def test_exclude_filter_bad(): +def test_is_exclude_by_filter_bad(): """Test exclude filter.""" - filter_funct = exclude_filter(["*.txt", "data/*", "bla/blu/ble"]) + filter_list = ["*.txt", "data/*", "bla/blu/ble"] test_list = [ - TarInfo("test.txt"), - TarInfo("data/xy.blob"), - TarInfo("bla/blu/ble"), - TarInfo("data/test_files/kk.txt"), + PurePath("test.txt"), + PurePath("data/xy.blob"), + PurePath("bla/blu/ble"), + PurePath("data/test_files/kk.txt"), ] - for info in [filter_funct(result) for result in test_list]: - assert info is None + for path_object in test_list: + assert _is_excluded_by_filter(path_object, filter_list) is True