From 6ebb1e7ae4bd1d9a7b35ea276a3a3edb78bed092 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20G=C3=B6rner?= <5477952+MaxG87@users.noreply.github.com> Date: Sun, 26 Apr 2026 00:46:57 +0200 Subject: [PATCH 01/10] feat: Reject names that form absolute Paths If a user is able to write to /, the mkdir based test would fail to reject that. BREAKING-CHANGE: Names that form absolute paths are rejected to avoid bad surprises when using ButterBackup. --- src/butter_backup/config_parser.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/butter_backup/config_parser.py b/src/butter_backup/config_parser.py index d74f1b0..4dd7744 100644 --- a/src/butter_backup/config_parser.py +++ b/src/butter_backup/config_parser.py @@ -65,6 +65,13 @@ def set_default_name(cls, data: Any) -> Any: @field_validator("Name") @classmethod def name_must_be_valid_path_component(cls, name: str) -> str: + if Path(name).is_absolute(): + # Appending an absolute path to another path would ignore the other path, so + # the combined path would point to the first absolute path and not to a + # subdirectory of the other path. + raise ValueError( + f"Name {name!r} ist ungültig, da er ein absoluter Pfad ist." + ) with TemporaryDirectory() as tmpdir: test_dir = Path(tmpdir) / name try: From 9bbbda47dd13ba758f88dfe88e03fde8ac616166 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20G=C3=B6rner?= <5477952+MaxG87@users.noreply.github.com> Date: Sat, 25 Apr 2026 20:32:32 +0200 Subject: [PATCH 02/10] test: Add exampes/ to dockerfiles to fix tests --- dockerfiles/arch | 1 + dockerfiles/debian | 1 + dockerfiles/python3.11 | 1 + dockerfiles/python3.12 | 1 + dockerfiles/python3.13 | 1 + dockerfiles/python3.14 | 1 + 6 files changed, 6 insertions(+) diff --git a/dockerfiles/arch b/dockerfiles/arch index 2f377f4..2adaa56 100644 --- a/dockerfiles/arch +++ b/dockerfiles/arch @@ -41,6 +41,7 @@ RUN touch README.md # Content is not relevant for this Dockerfile COPY pyproject.toml uv.lock ./ RUN uv sync --frozen --no-install-project +COPY examples/ examples/ COPY src/ src/ RUN uv sync --frozen diff --git a/dockerfiles/debian b/dockerfiles/debian index 50b3661..f20bc57 100644 --- a/dockerfiles/debian +++ b/dockerfiles/debian @@ -16,6 +16,7 @@ COPY pyproject.toml uv.lock ./ RUN uv sync --frozen --no-install-project # Install project +COPY examples/ examples/ COPY src/ src/ RUN uv sync --frozen diff --git a/dockerfiles/python3.11 b/dockerfiles/python3.11 index ebcc41a..a891df5 100644 --- a/dockerfiles/python3.11 +++ b/dockerfiles/python3.11 @@ -17,6 +17,7 @@ COPY pyproject.toml uv.lock ./ RUN uv sync --frozen --no-install-project # Install project +COPY examples/ examples/ COPY src/ src/ RUN uv sync --frozen diff --git a/dockerfiles/python3.12 b/dockerfiles/python3.12 index 945ff00..767d654 100644 --- a/dockerfiles/python3.12 +++ b/dockerfiles/python3.12 @@ -17,6 +17,7 @@ COPY pyproject.toml uv.lock ./ RUN uv sync --frozen --no-install-project # Install project +COPY examples/ examples/ COPY src/ src/ RUN uv sync --frozen diff --git a/dockerfiles/python3.13 b/dockerfiles/python3.13 index f9f735c..58a3eef 100644 --- a/dockerfiles/python3.13 +++ b/dockerfiles/python3.13 @@ -17,6 +17,7 @@ COPY pyproject.toml uv.lock ./ RUN uv sync --frozen --no-install-project # Install project +COPY examples/ examples/ COPY src/ src/ RUN uv sync --frozen diff --git a/dockerfiles/python3.14 b/dockerfiles/python3.14 index 97bf35e..a59098b 100644 --- a/dockerfiles/python3.14 +++ b/dockerfiles/python3.14 @@ -17,6 +17,7 @@ COPY pyproject.toml uv.lock ./ RUN uv sync --frozen --no-install-project # Install project +COPY examples/ examples/ COPY src/ src/ RUN uv sync --frozen From 31435daa9fd8c3c6998589c70639946b1fc0ea00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20G=C3=B6rner?= <5477952+MaxG87@users.noreply.github.com> Date: Sun, 26 Apr 2026 09:39:09 +0200 Subject: [PATCH 03/10] test: Sync file system for Restic backend to fix unmount error This is a desperate attempt to fix a bug in the unmount path. This didn't work in a first try but seems to be acceptable. --- src/butter_backup/backup_backends.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/butter_backup/backup_backends.py b/src/butter_backup/backup_backends.py index e806997..3018dea 100644 --- a/src/butter_backup/backup_backends.py +++ b/src/butter_backup/backup_backends.py @@ -153,6 +153,11 @@ def adapt_ownership(backup_repository: Path) -> None: ) sdm.chown(backup_repository, user, group, recursive=True) + @staticmethod + def sync_filesystem_changes(mount_dir: Path) -> None: + sync_cmd: sh.StrPathList = ["sudo", "sync", "-f", mount_dir] + sh.run_cmd(cmd=sync_cmd) + def copy_files(self, backup_repository: Path) -> None: restic_cmd: sh.StrPathList = [ "sudo", @@ -166,3 +171,4 @@ def copy_files(self, backup_repository: Path) -> None: restic_cmd.extend(["--exclude-file", self.config.ExcludePatternsFile]) restic_cmd.extend(list(self.config.FilesAndFolders)) sh.pipe_pass_cmd_to_real_cmd(self.config.RepositoryPassCmd, restic_cmd) + self.sync_filesystem_changes(backup_repository) From 4ab1b893c43bd1cd261231934eb6da0bdd39e9af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20G=C3=B6rner?= <5477952+MaxG87@users.noreply.github.com> Date: Sat, 25 Apr 2026 20:16:17 +0200 Subject: [PATCH 04/10] refactor: Provide compression in a more elegant way --- src/butter_backup/cli.py | 9 +-------- src/butter_backup/config_parser.py | 12 +++++++++++- tests/conftest.py | 7 +------ 3 files changed, 13 insertions(+), 15 deletions(-) diff --git a/src/butter_backup/cli.py b/src/butter_backup/cli.py index 3394644..5fac216 100644 --- a/src/butter_backup/cli.py +++ b/src/butter_backup/cli.py @@ -207,14 +207,7 @@ def backup(config: Path = CONFIG_OPTION, verbose: int = VERBOSITY_OPTION) -> Non continue backend = bb.BackupBackend.from_config(cfg) with sdm.decrypted_device(cfg.device(), cfg.DevicePassCmd) as decrypted: - match cfg: - case cp.BtrFSRsyncConfig(): - compression = cfg.Compression - case cp.ResticConfig(): - compression = None - case _: - t.assert_never(cfg) - with sdm.mounted_device(decrypted, compression) as mount_dir: + with sdm.mounted_device(decrypted, cfg.compression()) as mount_dir: backend.do_backup(mount_dir) diff --git a/src/butter_backup/config_parser.py b/src/butter_backup/config_parser.py index 4dd7744..9ffe23b 100644 --- a/src/butter_backup/config_parser.py +++ b/src/butter_backup/config_parser.py @@ -1,3 +1,4 @@ +import abc import json import sys import tomllib @@ -37,7 +38,7 @@ def path_aware_restic_json_decoding( return json.dumps(as_dict) -class BaseConfig(BaseModel): +class BaseConfig(BaseModel, abc.ABC): BackupRepositoryFolder: str DevicePassCmd: str ExcludePatternsFile: FilePath | None = None @@ -99,6 +100,9 @@ def device(self) -> Path: def map_name(self) -> Path: return Path(f"/dev/mapper/{self.UUID}") + @abc.abstractmethod + def compression(self) -> ValidCompressions | None: ... + class BtrFSRsyncConfig(BaseConfig): model_config = ConfigDict(extra="forbid", frozen=True) @@ -156,6 +160,9 @@ def raise_with_message_upon_duplicate( ) raise ValueError(f"{errmsg_begin} {errmsg_body}") + def compression(self) -> ValidCompressions | None: + return self.Compression + class ResticConfig(BaseConfig): model_config = ConfigDict(extra="forbid", frozen=True) @@ -167,6 +174,9 @@ def expand_tilde_in_sources(cls, files_and_folders) -> set[str]: new = {str(Path(src).expanduser()) for src in files_and_folders} return new + def compression(self) -> None: + return None + Configuration = BtrFSRsyncConfig | ResticConfig diff --git a/tests/conftest.py b/tests/conftest.py index bf59281..62f7ee8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -119,13 +119,8 @@ def encrypted_device(request) -> cp.Configuration: @pytest.fixture def mounted_device(encrypted_device) -> t.Iterator[tuple[cp.Configuration, Path]]: config = encrypted_device - match config: - case cp.BtrFSRsyncConfig(): - compression = config.Compression - case cp.ResticConfig(): - compression = None with sdm.decrypted_device(config.device(), config.DevicePassCmd) as decrypted: - with sdm.mounted_device(decrypted, compression) as mounted_device: + with sdm.mounted_device(decrypted, config.compression()) as mounted_device: if isinstance(config, cp.BtrFSRsyncConfig): # Ensure `FilesDest` is a file, initially. This ensures correct handling # of single files backup, even if an existing backup suffered from the From 226e4d68c2a4c529a5ad3222ecdaea45eba0e59e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20G=C3=B6rner?= <5477952+MaxG87@users.noreply.github.com> Date: Sat, 25 Apr 2026 20:15:35 +0200 Subject: [PATCH 05/10] refactor: Use cfg.Configuration instead of BaseConfig --- src/butter_backup/cli.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/butter_backup/cli.py b/src/butter_backup/cli.py index 5fac216..91fc244 100644 --- a/src/butter_backup/cli.py +++ b/src/butter_backup/cli.py @@ -72,10 +72,10 @@ def _get_default_file_system(backend: ValidBackends) -> ValidFileSystems: def _skip_device( - config: cp.BaseConfig, + config: cp.Configuration, *, - log_missing: Callable[[cp.BaseConfig], None] | None = None, - log_opened: Callable[[cp.BaseConfig], None] | None = None, + log_missing: Callable[[cp.Configuration], None] | None = None, + log_opened: Callable[[cp.Configuration], None] | None = None, ) -> bool: """ Helper function to determine whether a device should be skipped. @@ -264,7 +264,7 @@ def format_device( "Zieldatei für ButterBackup-Konfiguration existiert schon!" ) config_writer = config_to.write_text - config: cp.BaseConfig + config: cp.Configuration match backend: case ValidBackends.btrfs_rsync: config = prepare_device_for_butterbackend(device) From 89175af895e3154793e1a3233d0aaee93b62cbd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20G=C3=B6rner?= <5477952+MaxG87@users.noreply.github.com> Date: Sat, 25 Apr 2026 20:14:39 +0200 Subject: [PATCH 06/10] refactor: Use t.assert_never in one occasion --- src/butter_backup/cli.py | 11 ++--- src/butter_backup/device_managers.py | 5 +-- tests/__init__.py | 34 +++++++-------- tests/test_backup_backends.py | 65 ++++++++++++---------------- tests/test_cli.py | 11 ++--- 5 files changed, 51 insertions(+), 75 deletions(-) diff --git a/src/butter_backup/cli.py b/src/butter_backup/cli.py index 91fc244..a65a751 100644 --- a/src/butter_backup/cli.py +++ b/src/butter_backup/cli.py @@ -65,10 +65,8 @@ def _get_default_file_system(backend: ValidBackends) -> ValidFileSystems: return ValidFileSystems.btrfs case ValidBackends.restic: return ValidFileSystems.ext4 - # TODO: Use t.assert_never when Python 3.11 is the minimum version! - raise ValueError( - f"Unsupported backend: {backend}. Expected one of: {list(ValidBackends)}" - ) + case _: + t.assert_never(backend) def _skip_device( @@ -271,10 +269,7 @@ def format_device( case ValidBackends.restic: config = prepare_device_for_resticbackend(device, file_system.value) case _: - # TODO: Use t.assert_never when Python 3.11 is the minimum version! - raise ValueError( - f"Unsupported backend: {backend}. Expected one of: {list(ValidBackends)}" - ) + t.assert_never(backend) json_serialisable = json.loads(config.model_dump_json(exclude_none=True)) config_writer(json.dumps([json_serialisable], indent=4, sort_keys=True)) diff --git a/src/butter_backup/device_managers.py b/src/butter_backup/device_managers.py index 964e716..5544aa0 100644 --- a/src/butter_backup/device_managers.py +++ b/src/butter_backup/device_managers.py @@ -17,10 +17,7 @@ def format_device(device: Path, file_system: ValidFileSystems) -> None: case "btrfs": sdm.mkfs_btrfs(device) case _: - # TODO: Use t.assert_never when Python 3.11 is the minimum version! - raise ValueError( - f"Unsupported file system {file_system} for Restic backend." - ) + t.assert_never(file_system) def mkfs_ext4(device: Path) -> None: diff --git a/tests/__init__.py b/tests/__init__.py index cbac1e6..dff53ca 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -31,21 +31,19 @@ def complement_configuration( source_dir / "etc" / "fstab", source_dir / "cache" / "randomfile.bin", } - if isinstance(config, cp.BtrFSRsyncConfig): - folder_dest_dir = "some-folder-name" - return config.model_copy( - update={ - "Folders": {folders_root: folder_dest_dir}, - "Files": single_files, - "FilesDest": "Einzeldateien", - } - ) - if isinstance(config, cp.ResticConfig): - return config.model_copy( - update={"FilesAndFolders": {folders_root}.union(single_files)} - ) - # TODO: Use t.assert_never when Python 3.11 is the minimum version! - raise TypeError( - f"Unsupported configuration type: {type(config).__name__}. " - "Expected BtrFSRsyncConfig or ResticConfig." - ) + match config: + case cp.BtrFSRsyncConfig(): + folder_dest_dir = "some-folder-name" + return config.model_copy( + update={ + "Folders": {folders_root: folder_dest_dir}, + "Files": single_files, + "FilesDest": "Einzeldateien", + } + ) + case cp.ResticConfig(): + return config.model_copy( + update={"FilesAndFolders": {folders_root}.union(single_files)} + ) + case _: + t.assert_never(config) diff --git a/tests/test_backup_backends.py b/tests/test_backup_backends.py index 03bb8bb..2165e9f 100644 --- a/tests/test_backup_backends.py +++ b/tests/test_backup_backends.py @@ -57,38 +57,32 @@ def get_expected_content( config: cp.Configuration, exclude_to_ignore_file: bool, ) -> Counter[bytes] | dict[Path, bytes]: - if isinstance(config, cp.BtrFSRsyncConfig): - source_dirs = set(config.Folders) - source_files = config.Files - elif isinstance(config, cp.ResticConfig): - source_dirs = {cur for cur in config.FilesAndFolders if cur.is_dir()} - source_files = {cur for cur in config.FilesAndFolders if cur.is_file()} - else: - # TODO: Use t.assert_never when Python 3.11 is the minimum version! - raise TypeError( - f"Unsupported configuration type: {type(config).__name__}. " - "Expected BtrFSRsyncConfig or ResticConfig." - ) + match config: + case cp.BtrFSRsyncConfig(): + source_dirs = set(config.Folders) + source_files = config.Files + case cp.ResticConfig(): + source_dirs = {cur for cur in config.FilesAndFolders if cur.is_dir()} + source_files = {cur for cur in config.FilesAndFolders if cur.is_file()} + case _: + t.assert_never(config) expected_content_dirs = get_expected_content_recursive_dir( source_dirs, exclude_to_ignore_file ) expected_content_files = get_expected_content_single_files(source_files) - if isinstance(config, cp.BtrFSRsyncConfig): - expected_content_files_by_path = { - Path(config.FilesDest) / key: value - for key, value in expected_content_files.items() - } - return expected_content_dirs | expected_content_files_by_path - elif isinstance(config, cp.ResticConfig): - expected_content = expected_content_dirs | expected_content_files - return Counter(expected_content.values()) - else: - # TODO: Use t.assert_never when Python 3.11 is the minimum version! - raise TypeError( - f"Unsupported configuration type: {type(config).__name__}. " - "Expected BtrFSRsyncConfig or ResticConfig." - ) + match config: + case cp.BtrFSRsyncConfig(): + expected_content_files_by_path = { + Path(config.FilesDest) / key: value + for key, value in expected_content_files.items() + } + return expected_content_dirs | expected_content_files_by_path + case cp.ResticConfig(): + expected_content = expected_content_dirs | expected_content_files + return Counter(expected_content.values()) + case _: + t.assert_never(config) def get_expected_content_recursive_dir( @@ -124,16 +118,13 @@ def get_result_content(config: cp.ResticConfig, mounted: Path) -> Counter[bytes] def get_result_content( config: cp.Configuration, mounted: Path ) -> Counter[bytes] | dict[Path, bytes]: - if isinstance(config, cp.BtrFSRsyncConfig): - return get_result_content_for_btrfs(config, mounted) - elif isinstance(config, cp.ResticConfig): - return get_result_content_for_restic(config, mounted) - else: - # TODO: Use t.assert_never when Python 3.11 is the minimum version! - raise TypeError( - f"Unsupported configuration type: {type(config).__name__}. " - "Expected BtrFSRsyncConfig or ResticConfig." - ) + match config: + case cp.BtrFSRsyncConfig(): + return get_result_content_for_btrfs(config, mounted) + case cp.ResticConfig(): + return get_result_content_for_restic(config, mounted) + case _: + t.assert_never(config) def get_result_content_for_btrfs( diff --git a/tests/test_cli.py b/tests/test_cli.py index ff90875..3736040 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,6 +1,7 @@ import datetime as dt import re import time +import typing as t from pathlib import Path from tempfile import NamedTemporaryFile, TemporaryDirectory from unittest import mock @@ -21,19 +22,13 @@ def in_docker_container() -> bool: return Path("/.dockerenv").exists() -def prepare_tmp_path( - config: cp.BtrFSRsyncConfig | cp.ResticConfig, parent: Path -) -> None: +def prepare_tmp_path(config: cp.Configuration, parent: Path) -> None: if isinstance(config, cp.BtrFSRsyncConfig): prepare_tmp_path_for_btrfs(config, parent) elif isinstance(config, cp.ResticConfig): prepare_tmp_path_for_restic(config) else: - # TODO: Use t.assert_never when Python 3.11 is the minimum version! - raise TypeError( - f"Unsupported configuration type: {type(config).__name__}. " - "Expected BtrFSRsyncConfig or ResticConfig." - ) + t.assert_never(config) def prepare_tmp_path_for_btrfs(config: cp.BtrFSRsyncConfig, parent: Path) -> None: From 90c190efe0ea1043d48d1a92900ec7363f5dd6d0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Apr 2026 13:34:02 +0000 Subject: [PATCH 07/10] feat: open mounts all devices to shared tmpdir under Name subdirectory with error handling Agent-Logs-Url: https://github.com/MaxG87/ButterBackup/sessions/23d2a9a9-70b1-4f5d-9d34-699123bebba2 Co-authored-by: MaxG87 <5477952+MaxG87@users.noreply.github.com> --- src/butter_backup/cli.py | 33 +++++++++++++++++++++------------ tests/hypothesis_utils.py | 2 +- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/src/butter_backup/cli.py b/src/butter_backup/cli.py index a65a751..2fde1fb 100644 --- a/src/butter_backup/cli.py +++ b/src/butter_backup/cli.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +import contextlib import enum import json import os @@ -101,6 +102,24 @@ def _skip_device( VERBOSITY_OPTION = typer.Option(0, "--verbose", "-v", count=True) +def _open_device(cfg: cp.Configuration, base_dir: Path) -> None: + mount_dir = base_dir / cfg.Name + mount_dir.mkdir(exist_ok=True) + try: + decrypted = sdm.open_encrypted_device(cfg.device(), cfg.DevicePassCmd) + sdm.mount_btrfs_device( + decrypted, mount_dir=mount_dir, compression=cfg.compression() + ) + except: + typer.echo( + f"Speichermedium {cfg.Name} konnte nicht geöffnet werden. Es wird übersprungen." + ) + with contextlib.suppress(OSError): + mount_dir.rmdir() + else: + typer.echo(f"Speichermedium {cfg.Name} wurde in {mount_dir} geöffnet.") + + @app.command() def open( # noqa: A001 config: Path = CONFIG_OPTION, verbose: int = VERBOSITY_OPTION @@ -121,6 +140,7 @@ def open( # noqa: A001 """ setup_logging(verbose) configurations = cp.parse_configuration(config.read_text()) + tmp_dir = Path(mkdtemp()) for cfg in configurations: if _skip_device( cfg, @@ -129,18 +149,7 @@ def open( # noqa: A001 ), ): continue - mount_dir = Path(mkdtemp()) - decrypted = sdm.open_encrypted_device(cfg.device(), cfg.DevicePassCmd) - match cfg: - case cp.BtrFSRsyncConfig(): - sdm.mount_btrfs_device( - decrypted, mount_dir=mount_dir, compression=cfg.Compression - ) - case cp.ResticConfig(): - sdm.mount_btrfs_device(decrypted, mount_dir=mount_dir) - case _: - t.assert_never(cfg) - typer.echo(f"Speichermedium {cfg.Name} wurde in {mount_dir} geöffnet.") + _open_device(cfg, tmp_dir) @app.command() diff --git a/tests/hypothesis_utils.py b/tests/hypothesis_utils.py index 2e23564..9b45dcf 100644 --- a/tests/hypothesis_utils.py +++ b/tests/hypothesis_utils.py @@ -18,7 +18,7 @@ def filenames(draw, min_size=1) -> str: @st.composite def valid_path_components(draw, min_size=1) -> str: name: str = draw( - st.text(min_size=min_size).filter( + st.text(min_size=min_size, max_size=128).filter( lambda n: "/" not in n and "\x00" not in n and n not in {".", ".."} ) ) From ba7faa6c9c0905656d406b505b8ed9d03f79a22e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Apr 2026 13:34:27 +0000 Subject: [PATCH 08/10] feat: add optional dest argument to open command Agent-Logs-Url: https://github.com/MaxG87/ButterBackup/sessions/23d2a9a9-70b1-4f5d-9d34-699123bebba2 Co-authored-by: MaxG87 <5477952+MaxG87@users.noreply.github.com> --- src/butter_backup/cli.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/butter_backup/cli.py b/src/butter_backup/cli.py index 2fde1fb..bc12c6e 100644 --- a/src/butter_backup/cli.py +++ b/src/butter_backup/cli.py @@ -122,7 +122,9 @@ def _open_device(cfg: cp.Configuration, base_dir: Path) -> None: @app.command() def open( # noqa: A001 - config: Path = CONFIG_OPTION, verbose: int = VERBOSITY_OPTION + dest: Path | None = typer.Argument(None), # noqa: B008 + config: Path = CONFIG_OPTION, + verbose: int = VERBOSITY_OPTION, ) -> None: """ Öffne alle in der Konfiguration gelisteten Speichermedien @@ -137,10 +139,14 @@ def open( # noqa: A001 kann mit den Daten interagiert werden, z.B. durch Öffnen im Dateibrowser oder durch Verwendung von `restic`. Nach erfolgreicher Wiederherstellung kann das Speichermedium mit `butter-backup close` wieder entfernt werden. + + Optional kann ein Zielverzeichnis angegeben werden. Wenn angegeben, werden + die Speichermedien in Unterverzeichnissen dieses Verzeichnisses gemountet. + Andernfalls wird ein temporäres Verzeichnis erstellt. """ setup_logging(verbose) configurations = cp.parse_configuration(config.read_text()) - tmp_dir = Path(mkdtemp()) + base_dir = dest if dest is not None else Path(mkdtemp()) for cfg in configurations: if _skip_device( cfg, @@ -149,7 +155,7 @@ def open( # noqa: A001 ), ): continue - _open_device(cfg, tmp_dir) + _open_device(cfg, base_dir) @app.command() From 09396b6f933a4bd9544c774d8b7bafc03f9a95d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20G=C3=B6rner?= <5477952+MaxG87@users.noreply.github.com> Date: Sat, 25 Apr 2026 17:21:25 +0200 Subject: [PATCH 09/10] Stop removing mount dir after closing device This will allow a new way of operation. `open` will open the device to a static directory, e.g. `/mnt/My_Device`, so it is reliably there. For a better overview, the mountpoint should remain. --- src/butter_backup/cli.py | 1 - tests/test_cli.py | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/butter_backup/cli.py b/src/butter_backup/cli.py index bc12c6e..ac26247 100644 --- a/src/butter_backup/cli.py +++ b/src/butter_backup/cli.py @@ -182,7 +182,6 @@ def close(config: Path = CONFIG_OPTION, verbose: int = VERBOSITY_OPTION) -> None mount_dir = next(iter(mount_dirs)) sdm.unmount_device(mount_dir) sdm.close_decrypted_device(Path(mapped_device)) - mount_dir.rmdir() @app.command() diff --git a/tests/test_cli.py b/tests/test_cli.py index 3736040..0bd10f4 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -214,7 +214,7 @@ def test_open_close_roundtrip(runner, encrypted_device) -> None: runner.invoke(app, ["close", "--config", str(config_file)]) assert not expected_cryptsetup_map.exists() assert not sdm.is_mounted(mount_dest) - assert not mount_dest.exists() + assert mount_dest.exists() # Target directory should be kept after closing. @pytest.mark.parametrize( @@ -408,5 +408,5 @@ def test_unmount_error_does_not_cause_content_deletion( mocker.stopall() result = runner.invoke(app, ["close", "--config", str(config_file)]) assert result.exit_code == 0 - assert not mount_of_device.exists() + assert mount_of_device.exists() # Target directory should be kept after closing. assert sdm.is_mounted(mount_of_device) is False From 9f54b408a97113b6e62bc4fb5eb2d7c211fca4fc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Apr 2026 22:38:28 +0000 Subject: [PATCH 10/10] test: add tests for open dest argument and failure handling Agent-Logs-Url: https://github.com/MaxG87/ButterBackup/sessions/c83949e8-d63d-42fb-a260-7556bf9dce81 Co-authored-by: MaxG87 <5477952+MaxG87@users.noreply.github.com> --- tests/test_cli.py | 52 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/tests/test_cli.py b/tests/test_cli.py index 0bd10f4..be7d76e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -217,6 +217,58 @@ def test_open_close_roundtrip(runner, encrypted_device) -> None: assert mount_dest.exists() # Target directory should be kept after closing. +@pytest.mark.parametrize("create_dest_subdir", [True, False]) +@pytest.mark.skipif( + in_docker_container(), reason="Test is known to fail in Docker container" +) +def test_open_with_explicit_dest( + runner, encrypted_device, create_dest_subdir: bool, tmp_path: Path +) -> None: + config = encrypted_device + expected_cryptsetup_map = Path(f"/dev/mapper/{config.UUID}") + config_file = tmp_path / "config.json" + config_file.write_text(f"[{config.model_dump_json()}]") + dest_dir = tmp_path / "mounts" + dest_dir.mkdir() + expected_mount_dir = dest_dir / config.Name + if create_dest_subdir: + expected_mount_dir.mkdir() + open_result = runner.invoke( + app, ["open", str(dest_dir), "--config", str(config_file)] + ) + assert open_result.exit_code == 0 + assert str(expected_mount_dir) in open_result.stdout + assert expected_cryptsetup_map.exists() + assert expected_mount_dir.exists() + mount_destinations = sdm.get_mounted_devices()[str(expected_cryptsetup_map)] + assert expected_mount_dir in mount_destinations + runner.invoke(app, ["close", "--config", str(config_file)]) + assert not expected_cryptsetup_map.exists() + assert not sdm.is_mounted(expected_mount_dir) + + +@pytest.mark.skipif( + in_docker_container(), reason="Test is known to fail in Docker container" +) +def test_open_shows_error_on_failure(runner, encrypted_device, tmp_path: Path) -> None: + # Use a wrong passphrase so that decryption fails naturally without any mocking. + config = encrypted_device.model_copy( + update={"DevicePassCmd": "echo wrong_password"} + ) + config_file = tmp_path / "config.json" + config_file.write_text(f"[{config.model_dump_json()}]") + dest_dir = tmp_path / "mounts" + dest_dir.mkdir() + open_result = runner.invoke( + app, ["open", str(dest_dir), "--config", str(config_file)] + ) + expected_msg = f"Speichermedium {config.Name} konnte nicht geöffnet werden. Es wird übersprungen." + assert open_result.exit_code == 0 + assert expected_msg in open_result.stdout + # The empty mount dir should have been cleaned up after the failure + assert not (dest_dir / config.Name).exists() + + @pytest.mark.parametrize( "backend", ["BackupBackend", "fvglxvleaeb", "NotYetImplementedBackend"] )