From fa49e1b641deb3182092b853d1f0eb2e4eff369b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 08:53:49 +0000 Subject: [PATCH 01/16] feat: add Configuration wrapper model with deviceConfigurations sublist Rename the existing `Configuration` union type alias to `DeviceConfiguration` and introduce a new `Configuration` BaseModel that wraps the list of device configurations under a `deviceConfigurations` field. For TOML, the parent section is now `butter-backup` with the array of device entries under `device-configurations`: [[butter-backup.device-configurations]] Name = "..." For JSON/JSON5/YAML the wrapper key is `deviceConfigurations`: { "deviceConfigurations": [...] } Update all callers, tests, and example config files accordingly. Agent-Logs-Url: https://github.com/MaxG87/ButterBackup/sessions/8df34a6f-2b3a-4151-8bd2-1d7d91224187 Co-authored-by: MaxG87 <5477952+MaxG87@users.noreply.github.com> --- examples/json.cfg | 42 +++++++++-------- examples/json5.cfg | 42 +++++++++-------- examples/toml.cfg | 8 ++-- examples/yaml.cfg | 33 ++++++------- src/butter_backup/cli.py | 25 +++++----- src/butter_backup/config_parser.py | 23 +++++----- tests/__init__.py | 4 +- .../config_parser/test_parse_configuration.py | 46 +++++++++++++++---- tests/conftest.py | 6 +-- tests/test_backup_backends.py | 8 ++-- tests/test_cli.py | 31 ++++++++----- 11 files changed, 155 insertions(+), 113 deletions(-) diff --git a/examples/json.cfg b/examples/json.cfg index 791c258..43b2a3a 100644 --- a/examples/json.cfg +++ b/examples/json.cfg @@ -1,20 +1,22 @@ -[ - { - "Name": "BtrFS Backup Example", - "UUID": "12345678-1234-5678-1234-567812345678", - "DevicePassCmd": "echo device-password", - "BackupRepositoryFolder": "ButterBackupRepo", - "Compression": "zstd:3", - "Folders": { "/tmp": "temp-files" }, - "Files": [], - "FilesDest": "single-files" - }, - { - "Name": "Restic Backup Example", - "UUID": "87654321-4321-8765-4321-876543218765", - "DevicePassCmd": "echo device-password", - "BackupRepositoryFolder": "ResticRepo", - "RepositoryPassCmd": "echo repo-password", - "FilesAndFolders": ["/tmp"] - } -] +{ + "deviceConfigurations": [ + { + "Name": "BtrFS Backup Example", + "UUID": "12345678-1234-5678-1234-567812345678", + "DevicePassCmd": "echo device-password", + "BackupRepositoryFolder": "ButterBackupRepo", + "Compression": "zstd:3", + "Folders": { "/tmp": "temp-files" }, + "Files": [], + "FilesDest": "single-files" + }, + { + "Name": "Restic Backup Example", + "UUID": "87654321-4321-8765-4321-876543218765", + "DevicePassCmd": "echo device-password", + "BackupRepositoryFolder": "ResticRepo", + "RepositoryPassCmd": "echo repo-password", + "FilesAndFolders": ["/tmp"] + } + ] +} diff --git a/examples/json5.cfg b/examples/json5.cfg index 9d9b21d..f3b3fb0 100644 --- a/examples/json5.cfg +++ b/examples/json5.cfg @@ -1,21 +1,23 @@ // JSON5 allows comments and trailing commas -[ - { - Name: "BtrFS Backup Example", - UUID: "12345678-1234-5678-1234-567812345678", - DevicePassCmd: "echo device-password", - BackupRepositoryFolder: "ButterBackupRepo", - Compression: "zstd:3", - Folders: { "/tmp": "temp-files" }, - Files: [], - FilesDest: "single-files", - }, - { - Name: "Restic Backup Example", - UUID: "87654321-4321-8765-4321-876543218765", - DevicePassCmd: "echo device-password", - BackupRepositoryFolder: "ResticRepo", - RepositoryPassCmd: "echo repo-password", - FilesAndFolders: ["/tmp"], - }, -] +{ + deviceConfigurations: [ + { + Name: "BtrFS Backup Example", + UUID: "12345678-1234-5678-1234-567812345678", + DevicePassCmd: "echo device-password", + BackupRepositoryFolder: "ButterBackupRepo", + Compression: "zstd:3", + Folders: { "/tmp": "temp-files" }, + Files: [], + FilesDest: "single-files", + }, + { + Name: "Restic Backup Example", + UUID: "87654321-4321-8765-4321-876543218765", + DevicePassCmd: "echo device-password", + BackupRepositoryFolder: "ResticRepo", + RepositoryPassCmd: "echo repo-password", + FilesAndFolders: ["/tmp"], + }, + ], +} diff --git a/examples/toml.cfg b/examples/toml.cfg index 9a92e10..93dddcf 100644 --- a/examples/toml.cfg +++ b/examples/toml.cfg @@ -1,7 +1,7 @@ # TOML configuration for butter-backup -# Each [[DEVICE_CONFIGURATION]] section defines one device configuration +# Each [[butter-backup.device-configurations]] section defines one device configuration -[[DEVICE_CONFIGURATION]] +[[butter-backup.device-configurations]] Name = "BtrFS Backup Example" UUID = "12345678-1234-5678-1234-567812345678" DevicePassCmd = "echo device-password" @@ -10,10 +10,10 @@ Compression = "zstd:3" Files = [] FilesDest = "single-files" -[DEVICE_CONFIGURATION.Folders] +[butter-backup.device-configurations.Folders] "/tmp" = "temp-files" -[[DEVICE_CONFIGURATION]] +[[butter-backup.device-configurations]] Name = "Restic Backup Example" UUID = "87654321-4321-8765-4321-876543218765" DevicePassCmd = "echo device-password" diff --git a/examples/yaml.cfg b/examples/yaml.cfg index f0299b5..5cd9e8b 100644 --- a/examples/yaml.cfg +++ b/examples/yaml.cfg @@ -1,18 +1,19 @@ --- -- Name: "BtrFS Backup Example" - UUID: "12345678-1234-5678-1234-567812345678" - DevicePassCmd: "echo device-password" - BackupRepositoryFolder: "ButterBackupRepo" - Compression: "zstd:3" - Folders: - /tmp: "temp-files" - Files: [] - FilesDest: "single-files" +deviceConfigurations: + - Name: "BtrFS Backup Example" + UUID: "12345678-1234-5678-1234-567812345678" + DevicePassCmd: "echo device-password" + BackupRepositoryFolder: "ButterBackupRepo" + Compression: "zstd:3" + Folders: + /tmp: "temp-files" + Files: [] + FilesDest: "single-files" -- Name: "Restic Backup Example" - UUID: "87654321-4321-8765-4321-876543218765" - DevicePassCmd: "echo device-password" - BackupRepositoryFolder: "ResticRepo" - RepositoryPassCmd: "echo repo-password" - FilesAndFolders: - - /tmp + - Name: "Restic Backup Example" + UUID: "87654321-4321-8765-4321-876543218765" + DevicePassCmd: "echo device-password" + BackupRepositoryFolder: "ResticRepo" + RepositoryPassCmd: "echo repo-password" + FilesAndFolders: + - /tmp diff --git a/src/butter_backup/cli.py b/src/butter_backup/cli.py index 71dd2a4..229609e 100644 --- a/src/butter_backup/cli.py +++ b/src/butter_backup/cli.py @@ -71,10 +71,10 @@ def _get_default_file_system(backend: ValidBackends) -> ValidFileSystems: def _skip_device( - config: cp.Configuration, + config: cp.DeviceConfiguration, *, - log_missing: Callable[[cp.Configuration], None] | None = None, - log_opened: Callable[[cp.Configuration], None] | None = None, + log_missing: Callable[[cp.DeviceConfiguration], None] | None = None, + log_opened: Callable[[cp.DeviceConfiguration], None] | None = None, ) -> bool: """ Helper function to determine whether a device should be skipped. @@ -102,7 +102,7 @@ def _skip_device( VERBOSITY_OPTION = typer.Option(0, "--verbose", "-v", count=True) -def _open_device(cfg: cp.Configuration, base_dir: Path) -> None: +def _open_device(cfg: cp.DeviceConfiguration, base_dir: Path) -> None: mount_dir = base_dir / cfg.Name mount_dir.mkdir(exist_ok=True) try: @@ -143,9 +143,9 @@ def open( # noqa: A001 Andernfalls wird ein temporäres Verzeichnis erstellt. """ setup_logging(verbose) - configurations = cp.parse_configuration(config.read_text()) + parsed_config = cp.parse_configuration(config.read_text()) base_dir = dest if dest is not None else Path(mkdtemp()) - for cfg in configurations: + for cfg in parsed_config.deviceConfigurations: if _skip_device( cfg, log_opened=lambda cfg: logger.warning( @@ -166,9 +166,9 @@ def close(config: Path = CONFIG_OPTION, verbose: int = VERBOSITY_OPTION) -> None `open`. Weitere Erklärungen finden sich dort. """ setup_logging(verbose) - configurations = cp.parse_configuration(config.read_text()) + parsed_config = cp.parse_configuration(config.read_text()) mounted_devices = sdm.get_mounted_devices() - for cfg in configurations: + for cfg in parsed_config.deviceConfigurations: map_name = cfg.map_name() map_name_as_str = str(map_name) if cfg.device().exists() and map_name_as_str in mounted_devices: @@ -207,8 +207,8 @@ def backup(config: Path = CONFIG_OPTION, verbose: int = VERBOSITY_OPTION) -> Non weitere manuelle Schritte sind nicht nötig. """ setup_logging(verbose) - configurations = cp.parse_configuration(config.read_text()) - for cfg in configurations: + parsed_config = cp.parse_configuration(config.read_text()) + for cfg in parsed_config.deviceConfigurations: if _skip_device( cfg, log_missing=lambda cfg: logger.info( @@ -278,7 +278,7 @@ def format_device( "Zieldatei für ButterBackup-Konfiguration existiert schon!" ) config_writer = config_to.write_text - config: cp.Configuration + config: cp.DeviceConfiguration match backend: case ValidBackends.btrfs_rsync: config = prepare_device_for_butterbackend(device) @@ -287,7 +287,8 @@ def format_device( case _: 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)) + wrapper = {"deviceConfigurations": [json_serialisable]} + config_writer(json.dumps(wrapper, indent=4, sort_keys=True)) @app.command() diff --git a/src/butter_backup/config_parser.py b/src/butter_backup/config_parser.py index 9ffe23b..4896272 100644 --- a/src/butter_backup/config_parser.py +++ b/src/butter_backup/config_parser.py @@ -17,7 +17,6 @@ DirectoryPath, Field, FilePath, - RootModel, field_validator, model_validator, ) @@ -178,15 +177,16 @@ def compression(self) -> None: return None -Configuration = BtrFSRsyncConfig | ResticConfig +DeviceConfiguration = BtrFSRsyncConfig | ResticConfig -class ConfigurationList(RootModel[list[Configuration]]): - root: list[Configuration] +class Configuration(BaseModel): + model_config = ConfigDict(frozen=True) + deviceConfigurations: list[DeviceConfiguration] @model_validator(mode="after") - def check_unique_names(self) -> "ConfigurationList": - name_counts = Counter(cfg.Name for cfg in self.root) + def check_unique_names(self) -> "Configuration": + name_counts = Counter(cfg.Name for cfg in self.deviceConfigurations) duplicates = [name for name, count in name_counts.items() if count > 1] if duplicates: raise ValueError( @@ -205,7 +205,8 @@ def _parse_as_json5(content: str) -> Any: def _parse_as_toml(content: str) -> Any: data = tomllib.loads(content) - return data["DEVICE_CONFIGURATION"] + bb = data["butter-backup"] + return {"deviceConfigurations": bb["device-configurations"]} def _parse_as_yaml(content: str) -> Any: @@ -220,15 +221,15 @@ def _parse_as_yaml(content: str) -> Any: ] -def parse_configuration(content: str) -> list[Configuration]: +def parse_configuration(content: str) -> Configuration: for parse_fn, exc_type in _PARSERS: try: raw = parse_fn(content) except exc_type: continue - config_lst = ConfigurationList.model_validate(raw) - if len(config_lst.root) == 0: + config = Configuration.model_validate(raw) + if len(config.deviceConfigurations) == 0: sys.exit("Leere Konfigurationsdateien sind nicht erlaubt.\n") - return config_lst.root + return config sys.exit("Konfigurationsdatei konnte nicht gelesen werden.\n") diff --git a/tests/__init__.py b/tests/__init__.py index dff53ca..fa594aa 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -23,8 +23,8 @@ def complement_configuration( def complement_configuration( - config: cp.Configuration, source_dir: Path -) -> cp.Configuration: + config: cp.DeviceConfiguration, source_dir: Path +) -> cp.DeviceConfiguration: folders_root = source_dir / "backup-root" single_files = { source_dir / "config" / "docker" / "daemon.json", diff --git a/tests/config_parser/test_parse_configuration.py b/tests/config_parser/test_parse_configuration.py index 57f00d3..892fb70 100644 --- a/tests/config_parser/test_parse_configuration.py +++ b/tests/config_parser/test_parse_configuration.py @@ -29,7 +29,7 @@ def test_example_files_can_be_parsed(example_file: Path) -> None: content = example_file.read_text() result = cp.parse_configuration(content) expected_names = ["BtrFS Backup Example", "Restic Backup Example"] - assert [cfg.Name for cfg in result] == expected_names + assert [cfg.Name for cfg in result.deviceConfigurations] == expected_names @given( @@ -58,7 +58,16 @@ def test_parse_configuration_rejects_duplicate_names_for_btrfs_rsync( ) btrfs_cfg_json = btrfs_cfg.model_dump_json() with pytest.raises(ValidationError): - cp.parse_configuration(f"[{btrfs_cfg_json}, {btrfs_cfg_json}]") + cp.parse_configuration( + json.dumps( + { + "deviceConfigurations": [ + json.loads(btrfs_cfg_json), + json.loads(btrfs_cfg_json), + ] + } + ) + ) @given( @@ -86,12 +95,21 @@ def test_parse_configuration_rejects_duplicate_names_for_restic( ) restic_cfg_json = restic_cfg.model_dump_json() with pytest.raises(ValidationError): - cp.parse_configuration(f"[{restic_cfg_json}, {restic_cfg_json}]") + cp.parse_configuration( + json.dumps( + { + "deviceConfigurations": [ + json.loads(restic_cfg_json), + json.loads(restic_cfg_json), + ] + } + ) + ) def test_parse_configuration_rejects_empty_list() -> None: with pytest.raises(SystemExit) as sysexit: - cp.parse_configuration("[]") + cp.parse_configuration(json.dumps({"deviceConfigurations": []})) assert sysexit.value.code not in SUCCESS_CODES @@ -102,12 +120,12 @@ def test_parse_configuration_rejects_empty_list() -> None: ) def test_parse_configuration_warns_on_non_lists(non_list) -> None: with pytest.raises(ValidationError): - cp.parse_configuration(json.dumps(non_list)) + cp.parse_configuration(json.dumps({"deviceConfigurations": non_list})) def test_parse_configuration_warns_on_non_dict_item() -> None: with pytest.raises(ValidationError): - cp.parse_configuration(json.dumps([{}, 1337])) + cp.parse_configuration(json.dumps({"deviceConfigurations": [{}, 1337]})) @given( @@ -134,8 +152,12 @@ def test_parse_configuration_parses_btrfs_config( Name=name, UUID=uuid, ) - cfg_lst = cp.parse_configuration(f"[{btrfs_cfg.model_dump_json()}]") - assert cfg_lst == [btrfs_cfg] + cfg_lst = cp.parse_configuration( + json.dumps( + {"deviceConfigurations": [json.loads(btrfs_cfg.model_dump_json())]} + ) + ) + assert cfg_lst.deviceConfigurations == [btrfs_cfg] @given( @@ -161,5 +183,9 @@ def test_load_configuration_parses_restic_config( RepositoryPassCmd=repository_pass_cmd, UUID=uuid, ) - cfg_lst = cp.parse_configuration(f"[{restic_cfg.model_dump_json()}]") - assert cfg_lst == [restic_cfg] + cfg_lst = cp.parse_configuration( + json.dumps( + {"deviceConfigurations": [json.loads(restic_cfg.model_dump_json())]} + ) + ) + assert cfg_lst.deviceConfigurations == [restic_cfg] diff --git a/tests/conftest.py b/tests/conftest.py index 62f7ee8..3588ba1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -111,13 +111,13 @@ def encrypted_restic_device( @pytest.fixture(params=["encrypted_btrfs_device", "encrypted_restic_device"]) -def encrypted_device(request) -> cp.Configuration: - config: cp.Configuration = request.getfixturevalue(request.param) +def encrypted_device(request) -> cp.DeviceConfiguration: + config: cp.DeviceConfiguration = request.getfixturevalue(request.param) return config @pytest.fixture -def mounted_device(encrypted_device) -> t.Iterator[tuple[cp.Configuration, Path]]: +def mounted_device(encrypted_device) -> t.Iterator[tuple[cp.DeviceConfiguration, Path]]: config = encrypted_device with sdm.decrypted_device(config.device(), config.DevicePassCmd) as decrypted: with sdm.mounted_device(decrypted, config.compression()) as mounted_device: diff --git a/tests/test_backup_backends.py b/tests/test_backup_backends.py index 2165e9f..4db47e9 100644 --- a/tests/test_backup_backends.py +++ b/tests/test_backup_backends.py @@ -28,11 +28,11 @@ def list_files_recursively(path: Path) -> Iterable[Path]: def run_backup_cycle( - base_config: cp.Configuration, + base_config: cp.DeviceConfiguration, source_dir: Path, device: Path, config_extension: dict[str, t.Any] | None = None, -) -> cp.Configuration: +) -> cp.DeviceConfiguration: config = complement_configuration(base_config, source_dir) if config_extension is not None: config = config.model_copy(update=config_extension) @@ -54,7 +54,7 @@ def get_expected_content( def get_expected_content( - config: cp.Configuration, + config: cp.DeviceConfiguration, exclude_to_ignore_file: bool, ) -> Counter[bytes] | dict[Path, bytes]: match config: @@ -116,7 +116,7 @@ def get_result_content(config: cp.ResticConfig, mounted: Path) -> Counter[bytes] def get_result_content( - config: cp.Configuration, mounted: Path + config: cp.DeviceConfiguration, mounted: Path ) -> Counter[bytes] | dict[Path, bytes]: match config: case cp.BtrFSRsyncConfig(): diff --git a/tests/test_cli.py b/tests/test_cli.py index f2876bd..87010ba 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -22,7 +22,7 @@ def in_docker_container() -> bool: return Path("/.dockerenv").exists() -def prepare_tmp_path(config: cp.Configuration, parent: Path) -> None: +def prepare_tmp_path(config: cp.DeviceConfiguration, parent: Path) -> None: if isinstance(config, cp.BtrFSRsyncConfig): prepare_tmp_path_for_btrfs(config, parent) elif isinstance(config, cp.ResticConfig): @@ -184,7 +184,8 @@ def test_close_does_not_close_unopened_device(runner, encrypted_btrfs_device) -> config = encrypted_btrfs_device with NamedTemporaryFile() as tempf: config_file = Path(tempf.name) - config_file.write_text(f"[{config.model_dump_json()}]") + wrapped_config = cp.Configuration(deviceConfigurations=[config]) + config_file.write_text(wrapped_config.model_dump_json()) close_result = runner.invoke(app, ["close", "--config", str(config_file)]) assert close_result.stdout == "" assert close_result.exit_code == 0 @@ -198,7 +199,8 @@ def test_open_close_roundtrip(runner, encrypted_device) -> None: expected_cryptsetup_map = Path(f"/dev/mapper/{config.UUID}") with NamedTemporaryFile() as tempf: config_file = Path(tempf.name) - config_file.write_text(f"[{config.model_dump_json()}]") + wrapped_config = cp.Configuration(deviceConfigurations=[config]) + config_file.write_text(wrapped_config.model_dump_json()) open_result = runner.invoke(app, ["open", "--config", str(config_file)]) expected_msg = ( f"Speichermedium {config.Name} wurde in (?P/[^ ]+) geöffnet." @@ -227,7 +229,8 @@ def test_open_with_explicit_dest( 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()}]") + wrapped_config = cp.Configuration(deviceConfigurations=[config]) + config_file.write_text(wrapped_config.model_dump_json()) dest_dir = tmp_path / "mounts" dest_dir.mkdir() expected_mount_dir = dest_dir / config.Name @@ -256,7 +259,8 @@ def test_open_shows_error_on_failure(runner, encrypted_device, tmp_path: Path) - update={"DevicePassCmd": "echo wrong_password"} ) config_file = tmp_path / "config.json" - config_file.write_text(f"[{config.model_dump_json()}]") + wrapped_config = cp.Configuration(deviceConfigurations=[config]) + config_file.write_text(wrapped_config.model_dump_json()) dest_dir = tmp_path / "mounts" dest_dir.mkdir() open_result = runner.invoke( @@ -335,7 +339,8 @@ def test_format_device_creates_expected_file_system( ) assert format_result.exit_code == 0 serialised_config = format_result.stdout - config_lst = list(cp.parse_configuration(serialised_config)) + parsed = cp.parse_configuration(serialised_config) + config_lst = parsed.deviceConfigurations assert len(config_lst) == 1 config = config_lst[0] with sdm.decrypted_device(big_file, config.DevicePassCmd) as decrypted: @@ -347,7 +352,8 @@ def test_format_device_creates_expected_file_system( def test_format_device(runner, backend: str, big_file: Path) -> None: format_result = runner.invoke(app, ["format-device", backend, str(big_file)]) serialised_config = format_result.stdout - config_lst = list(cp.parse_configuration(serialised_config)) + parsed = cp.parse_configuration(serialised_config) + config_lst = parsed.deviceConfigurations assert len(config_lst) == 1 device_uuid = config_lst[0].UUID device_name = config_lst[0].Name @@ -371,7 +377,8 @@ def test_format_device_chowns_filesystem_to_user( ) -> None: format_result = runner.invoke(app, ["format-device", backend, str(big_file)]) serialised_config = format_result.stdout - config_lst = list(cp.parse_configuration(serialised_config)) + parsed = cp.parse_configuration(serialised_config) + config_lst = parsed.deviceConfigurations assert len(config_lst) == 1 config = config_lst[0] @@ -405,7 +412,8 @@ def test_do_backup_refuses_backup_when_device_is_already_open( config_file = tmp_path / "config.json" - config_file.write_text(f"[{config.model_dump_json()}]") + wrapped_config = cp.Configuration(deviceConfigurations=[config]) + config_file.write_text(wrapped_config.model_dump_json()) runner.invoke(app, ["open", "--config", str(config_file)]) result = runner.invoke(app, [subprogram, "--config", str(config_file)]) expected_msg = ( @@ -417,7 +425,7 @@ def test_do_backup_refuses_backup_when_device_is_already_open( def test_unmount_error_does_not_cause_content_deletion( - runner: CliRunner, encrypted_device: cp.Configuration, tmp_path: Path, mocker + runner: CliRunner, encrypted_device: cp.DeviceConfiguration, tmp_path: Path, mocker ) -> None: # THIS IS A REGRESSION TEST! # @@ -439,7 +447,8 @@ def test_unmount_error_does_not_cause_content_deletion( config = complement_configuration(encrypted_device, tmp_path) prepare_tmp_path(config, tmp_path) config_file = tmp_path / "config.json" - config_file.write_text(f"[{config.model_dump_json()}]") + wrapped = cp.Configuration(deviceConfigurations=[config]) + config_file.write_text(wrapped.model_dump_json()) result = runner.invoke(app, ["backup", "--config", str(config_file)]) assert result.exit_code == 1 From 04569ff737d8e278fc2120534b569d0fec77418c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 08:54:03 +0000 Subject: [PATCH 02/16] feat: add SudoPassCmd field to Configuration Add an optional `SudoPassCmd: str | None = None` field to the `Configuration` model. When set, it holds a shell command whose stdout is a sudo password that can be used to refresh the sudo credential cache. Agent-Logs-Url: https://github.com/MaxG87/ButterBackup/sessions/8df34a6f-2b3a-4151-8bd2-1d7d91224187 Co-authored-by: MaxG87 <5477952+MaxG87@users.noreply.github.com> --- examples/json.cfg | 1 + examples/json5.cfg | 1 + examples/toml.cfg | 3 +++ examples/yaml.cfg | 1 + src/butter_backup/config_parser.py | 1 + 5 files changed, 7 insertions(+) diff --git a/examples/json.cfg b/examples/json.cfg index 43b2a3a..0f1f113 100644 --- a/examples/json.cfg +++ b/examples/json.cfg @@ -1,4 +1,5 @@ { + "SudoPassCmd": "gpg --decrypt ~/.local/share/passwords/sudo-password.gpg", "deviceConfigurations": [ { "Name": "BtrFS Backup Example", diff --git a/examples/json5.cfg b/examples/json5.cfg index f3b3fb0..143728c 100644 --- a/examples/json5.cfg +++ b/examples/json5.cfg @@ -1,5 +1,6 @@ // JSON5 allows comments and trailing commas { + SudoPassCmd: "gpg --decrypt ~/.local/share/passwords/sudo-password.gpg", deviceConfigurations: [ { Name: "BtrFS Backup Example", diff --git a/examples/toml.cfg b/examples/toml.cfg index 93dddcf..f7e1ee1 100644 --- a/examples/toml.cfg +++ b/examples/toml.cfg @@ -1,6 +1,9 @@ # TOML configuration for butter-backup # Each [[butter-backup.device-configurations]] section defines one device configuration +[butter-backup] +SudoPassCmd = "gpg --decrypt ~/.local/share/passwords/sudo-password.gpg" + [[butter-backup.device-configurations]] Name = "BtrFS Backup Example" UUID = "12345678-1234-5678-1234-567812345678" diff --git a/examples/yaml.cfg b/examples/yaml.cfg index 5cd9e8b..ec489de 100644 --- a/examples/yaml.cfg +++ b/examples/yaml.cfg @@ -1,4 +1,5 @@ --- +SudoPassCmd: "gpg --decrypt ~/.local/share/passwords/sudo-password.gpg" deviceConfigurations: - Name: "BtrFS Backup Example" UUID: "12345678-1234-5678-1234-567812345678" diff --git a/src/butter_backup/config_parser.py b/src/butter_backup/config_parser.py index 4896272..15524a1 100644 --- a/src/butter_backup/config_parser.py +++ b/src/butter_backup/config_parser.py @@ -183,6 +183,7 @@ def compression(self) -> None: class Configuration(BaseModel): model_config = ConfigDict(frozen=True) deviceConfigurations: list[DeviceConfiguration] + SudoPassCmd: str | None = None @model_validator(mode="after") def check_unique_names(self) -> "Configuration": From 7ddc0a3a2600b9b905c57ed54abcf79f05cf42b8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 08:55:49 +0000 Subject: [PATCH 03/16] feat: implement SudoPassCmd to refresh sudo cache When `SudoPassCmd` is set in the configuration, run `SudoPassCmd | sudo -Sv` to refresh the sudo credential cache: - before device decryption in the `open` command - before device decryption in the `backup` command - before unmounting in the `close` command This prevents interactive password prompts during long backup sessions. Agent-Logs-Url: https://github.com/MaxG87/ButterBackup/sessions/8df34a6f-2b3a-4151-8bd2-1d7d91224187 Co-authored-by: MaxG87 <5477952+MaxG87@users.noreply.github.com> --- src/butter_backup/cli.py | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/butter_backup/cli.py b/src/butter_backup/cli.py index 229609e..29403b7 100644 --- a/src/butter_backup/cli.py +++ b/src/butter_backup/cli.py @@ -9,6 +9,7 @@ from tempfile import mkdtemp from typing import Any, Callable +import shell_interface as sh import storage_device_managers as sdm import typer from loguru import logger @@ -70,6 +71,13 @@ def _get_default_file_system(backend: ValidBackends) -> ValidFileSystems: t.assert_never(backend) +def _refresh_sudo(sudo_pass_cmd: str | None) -> None: + if sudo_pass_cmd is not None: + sh.pipe_pass_cmd_to_real_cmd( + sudo_pass_cmd, ["sudo", "-Sv"], capture_output=True + ) + + def _skip_device( config: cp.DeviceConfiguration, *, @@ -102,10 +110,13 @@ def _skip_device( VERBOSITY_OPTION = typer.Option(0, "--verbose", "-v", count=True) -def _open_device(cfg: cp.DeviceConfiguration, base_dir: Path) -> None: +def _open_device( + cfg: cp.DeviceConfiguration, base_dir: Path, sudo_pass_cmd: str | None +) -> None: mount_dir = base_dir / cfg.Name mount_dir.mkdir(exist_ok=True) try: + _refresh_sudo(sudo_pass_cmd) decrypted = sdm.open_encrypted_device(cfg.device(), cfg.DevicePassCmd) sdm.mount_device(decrypted, mount_dir=mount_dir, compression=cfg.compression()) except: @@ -153,7 +164,7 @@ def open( # noqa: A001 ), ): continue - _open_device(cfg, base_dir) + _open_device(cfg, base_dir, parsed_config.SudoPassCmd) @app.command() @@ -182,6 +193,7 @@ def close(config: Path = CONFIG_OPTION, verbose: int = VERBOSITY_OPTION) -> None device=cfg.Name, ) continue + _refresh_sudo(parsed_config.SudoPassCmd) sdm.unmount_device(map_name) sdm.close_decrypted_device(map_name) @@ -220,9 +232,14 @@ def backup(config: Path = CONFIG_OPTION, verbose: int = VERBOSITY_OPTION) -> Non ): continue backend = bb.BackupBackend.from_config(cfg) + _refresh_sudo(parsed_config.SudoPassCmd) with sdm.decrypted_device(cfg.device(), cfg.DevicePassCmd) as decrypted: with sdm.mounted_device(decrypted, cfg.compression()) as mount_dir: backend.do_backup(mount_dir) + # A backup could take so long that the sudo session expires. In this + # case the user would have to enter the password again to unmount and + # close the device. To prevent this, the sudo session is refreshed. + _refresh_sudo(parsed_config.SudoPassCmd) @app.command() From 78c0c3925b8bcc74c14d67881953e7d558393c10 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 16 May 2026 11:19:08 +0000 Subject: [PATCH 04/16] feat: pass SudoPassCmd into do_backup and refresh sudo session in both backends Agent-Logs-Url: https://github.com/MaxG87/ButterBackup/sessions/e6ccb9d7-94a9-41e0-91ba-3fe37e740580 Co-authored-by: MaxG87 <5477952+MaxG87@users.noreply.github.com> --- src/butter_backup/backup_backends.py | 18 ++++++++-- src/butter_backup/cli.py | 2 +- tests/test_backup_backends.py | 53 ++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 4 deletions(-) diff --git a/src/butter_backup/backup_backends.py b/src/butter_backup/backup_backends.py index 14a64e9..c26ee75 100644 --- a/src/butter_backup/backup_backends.py +++ b/src/butter_backup/backup_backends.py @@ -11,9 +11,16 @@ from . import config_parser as cp +def _refresh_sudo(sudo_pass_cmd: str | None) -> None: + if sudo_pass_cmd is not None: + sh.pipe_pass_cmd_to_real_cmd( + sudo_pass_cmd, ["sudo", "-Sv"], capture_output=True + ) + + class BackupBackend(abc.ABC): @abc.abstractmethod - def do_backup(self, mount_dir: Path) -> None: ... + def do_backup(self, mount_dir: Path, sudo_pass_cmd: str | None = None) -> None: ... @overload @staticmethod @@ -37,7 +44,7 @@ def from_config( class BtrFSRsyncBackend(BackupBackend): config: cp.BtrFSRsyncConfig - def do_backup(self, mount_dir: Path) -> None: + def do_backup(self, mount_dir: Path, sudo_pass_cmd: str | None = None) -> None: logger.info(f"Beginne mit BtrFS-Backup für Speichermedium {self.config.Name}.") backup_repository = mount_dir / self.config.BackupRepositoryFolder src_snapshot = self.get_source_snapshot(backup_repository) @@ -45,10 +52,12 @@ def do_backup(self, mount_dir: Path) -> None: backup_root = self.snapshot( src=src_snapshot, backup_repository=backup_repository ) + _refresh_sudo(sudo_pass_cmd) self.adapt_ownership(backup_root) for src, dest_name in self.config.Folders.items(): dest = backup_root / dest_name + _refresh_sudo(sudo_pass_cmd) self.rsync_folder(src, dest, self.config.ExcludePatternsFile) files_dest = backup_root / self.config.FilesDest @@ -56,6 +65,7 @@ def do_backup(self, mount_dir: Path) -> None: files_dest.unlink() files_dest.mkdir(parents=True, exist_ok=True) for src in self.config.Files: + _refresh_sudo(sudo_pass_cmd) self.rsync_file(src, files_dest) @staticmethod @@ -129,10 +139,12 @@ def rsync_folder( class ResticBackend(BackupBackend): config: cp.ResticConfig - def do_backup(self, mount_dir: Path) -> None: + def do_backup(self, mount_dir: Path, sudo_pass_cmd: str | None = None) -> None: logger.info(f"Beginne mit Restic-Backup für Speichermedium {self.config.Name}.") backup_repository = mount_dir / self.config.BackupRepositoryFolder + _refresh_sudo(sudo_pass_cmd) self.copy_files(backup_repository) + _refresh_sudo(sudo_pass_cmd) self.adapt_ownership(backup_repository) @staticmethod diff --git a/src/butter_backup/cli.py b/src/butter_backup/cli.py index 29403b7..1593568 100644 --- a/src/butter_backup/cli.py +++ b/src/butter_backup/cli.py @@ -235,7 +235,7 @@ def backup(config: Path = CONFIG_OPTION, verbose: int = VERBOSITY_OPTION) -> Non _refresh_sudo(parsed_config.SudoPassCmd) with sdm.decrypted_device(cfg.device(), cfg.DevicePassCmd) as decrypted: with sdm.mounted_device(decrypted, cfg.compression()) as mount_dir: - backend.do_backup(mount_dir) + backend.do_backup(mount_dir, parsed_config.SudoPassCmd) # A backup could take so long that the sudo session expires. In this # case the user would have to enter the password again to unmount and # close the device. To prevent this, the sudo session is refreshed. diff --git a/tests/test_backup_backends.py b/tests/test_backup_backends.py index 4db47e9..1974f3b 100644 --- a/tests/test_backup_backends.py +++ b/tests/test_backup_backends.py @@ -6,6 +6,7 @@ from pathlib import Path from tempfile import TemporaryDirectory from typing import Iterable, overload +from uuid import uuid4 import pytest import shell_interface as sh @@ -315,3 +316,55 @@ def test_do_backup_for_restic_adapts_ownership( } assert found_user == {expected_user} assert found_group == {expected_group} + + +def test_btrfs_backend_refreshes_sudo_session_in_do_backup( + mocker, tmp_path: Path +) -> None: + sudo_pass_cmd = "echo test_password" + config = cp.BtrFSRsyncConfig( + BackupRepositoryFolder="repo", + DevicePassCmd="echo pass", + Files=set(), + FilesDest="files", + Folders={}, + Name="test-device", + UUID=uuid4(), + ) + backend = bb.BtrFSRsyncBackend(config=config) + mock_refresh = mocker.patch("butter_backup.backup_backends._refresh_sudo") + mocker.patch.object( + bb.BtrFSRsyncBackend, "get_source_snapshot", return_value=tmp_path + ) + mocker.patch.object(bb.BtrFSRsyncBackend, "snapshot", return_value=tmp_path) + mocker.patch.object(bb.BtrFSRsyncBackend, "adapt_ownership") + + backend.do_backup(tmp_path, sudo_pass_cmd) + + # Only one call before adapting ownership. The other calls are not covered by this + # test to keep the setup simple. + mock_refresh.assert_called_once_with(sudo_pass_cmd) + + +def test_restic_backend_refreshes_sudo_session_in_do_backup( + mocker, tmp_path: Path +) -> None: + sudo_pass_cmd = "echo test_password" + config = cp.ResticConfig( + BackupRepositoryFolder="repo", + DevicePassCmd="echo pass", + FilesAndFolders=set(), + Name="test-device", + RepositoryPassCmd="echo repo_pass", + UUID=uuid4(), + ) + backend = bb.ResticBackend(config=config) + mock_refresh = mocker.patch("butter_backup.backup_backends._refresh_sudo") + mocker.patch.object(bb.ResticBackend, "copy_files") + mocker.patch.object(bb.ResticBackend, "adapt_ownership") + + backend.do_backup(tmp_path, sudo_pass_cmd) + + expected_nof_calls = 2 + result_calls = mock_refresh.call_args_list + assert len(result_calls) == expected_nof_calls From 20d95d852fe2210ea6f90df7b6c2ea30bde05358 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20G=C3=B6rner?= <5477952+MaxG87@users.noreply.github.com> Date: Tue, 12 May 2026 22:45:19 +0200 Subject: [PATCH 05/16] refactor: Use t.Self as return type --- src/butter_backup/config_parser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/butter_backup/config_parser.py b/src/butter_backup/config_parser.py index 15524a1..8bb130d 100644 --- a/src/butter_backup/config_parser.py +++ b/src/butter_backup/config_parser.py @@ -186,7 +186,7 @@ class Configuration(BaseModel): SudoPassCmd: str | None = None @model_validator(mode="after") - def check_unique_names(self) -> "Configuration": + def check_unique_names(self) -> t.Self: name_counts = Counter(cfg.Name for cfg in self.deviceConfigurations) duplicates = [name for name, count in name_counts.items() if count > 1] if duplicates: From dab53aa817338b440d25a22a45ce321b4837d7d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20G=C3=B6rner?= <5477952+MaxG87@users.noreply.github.com> Date: Tue, 12 May 2026 23:05:16 +0200 Subject: [PATCH 06/16] docs(test): Add helpful comment to test helper --- tests/__init__.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/__init__.py b/tests/__init__.py index fa594aa..078256d 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -25,6 +25,12 @@ def complement_configuration( def complement_configuration( config: cp.DeviceConfiguration, source_dir: Path ) -> cp.DeviceConfiguration: + """ + Register to configuration some files and folders that should be backed up + + This is used to test the handling of both files and folders in the backup backends. + """ + folders_root = source_dir / "backup-root" single_files = { source_dir / "config" / "docker" / "daemon.json", From 10d3f0a20215986e9fa98e641f51c2b48bd2b0ad Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 16:14:50 +0000 Subject: [PATCH 07/16] test: add parser tests for SudoPassCmd optional behavior Agent-Logs-Url: https://github.com/MaxG87/ButterBackup/sessions/c3eca6b4-12ee-4225-864d-0f6c4d8eda57 Co-authored-by: MaxG87 <5477952+MaxG87@users.noreply.github.com> --- .../config_parser/test_parse_configuration.py | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/tests/config_parser/test_parse_configuration.py b/tests/config_parser/test_parse_configuration.py index 892fb70..7eb9ac2 100644 --- a/tests/config_parser/test_parse_configuration.py +++ b/tests/config_parser/test_parse_configuration.py @@ -189,3 +189,63 @@ def test_load_configuration_parses_restic_config( ) ) assert cfg_lst.deviceConfigurations == [restic_cfg] + + +@given( + backup_repository_folder=st.text(), + device_pass_cmd=st.text(), + name=hu.valid_path_components(), + repository_pass_cmd=st.text(), + sudo_pass_cmd=st.text(), + uuid=st.uuids(), +) +def test_parse_configuration_preserves_sudo_pass_cmd( # noqa: PLR0913 + backup_repository_folder: str, + device_pass_cmd: str, + name: str, + repository_pass_cmd: str, + sudo_pass_cmd: str, + uuid: UUID, +) -> None: + restic_cfg = cp.ResticConfig( + BackupRepositoryFolder=backup_repository_folder, + DevicePassCmd=device_pass_cmd, + FilesAndFolders={Path("/tmp")}, + Name=name, + RepositoryPassCmd=repository_pass_cmd, + UUID=uuid, + ) + raw = json.loads(restic_cfg.model_dump_json()) + config_json = json.dumps( + {"deviceConfigurations": [raw], "SudoPassCmd": sudo_pass_cmd} + ) + result = cp.parse_configuration(config_json) + assert result.SudoPassCmd == sudo_pass_cmd + + +@given( + backup_repository_folder=st.text(), + device_pass_cmd=st.text(), + name=hu.valid_path_components(), + repository_pass_cmd=st.text(), + uuid=st.uuids(), +) +def test_parse_configuration_defaults_sudo_pass_cmd_to_none( + backup_repository_folder: str, + device_pass_cmd: str, + name: str, + repository_pass_cmd: str, + uuid: UUID, +) -> None: + restic_cfg = cp.ResticConfig( + BackupRepositoryFolder=backup_repository_folder, + DevicePassCmd=device_pass_cmd, + FilesAndFolders={Path("/tmp")}, + Name=name, + RepositoryPassCmd=repository_pass_cmd, + UUID=uuid, + ) + raw = json.loads(restic_cfg.model_dump_json()) + config_json = json.dumps({"deviceConfigurations": [raw]}) + result = cp.parse_configuration(config_json) + assert result.SudoPassCmd is None From 5d1af11ee6da73ccfb6a0515c9228f2468c3d059 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 16:15:11 +0000 Subject: [PATCH 08/16] test: add mocked CLI propagation tests for SudoPassCmd Agent-Logs-Url: https://github.com/MaxG87/ButterBackup/sessions/c3eca6b4-12ee-4225-864d-0f6c4d8eda57 Co-authored-by: MaxG87 <5477952+MaxG87@users.noreply.github.com> --- tests/test_cli.py | 89 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/tests/test_cli.py b/tests/test_cli.py index 87010ba..391b866 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -467,3 +467,92 @@ def test_unmount_error_does_not_cause_content_deletion( assert result.exit_code == 0 assert mount_of_device.exists() # Target directory should be kept after closing. assert sdm.is_mounted(mount_of_device) is False + + +def test_sudo_pass_cmd_is_used_in_open( + runner: CliRunner, + encrypted_device: cp.DeviceConfiguration, + mocker, + tmp_path: Path, +) -> None: + sudo_pass_cmd = "echo test_password" + wrapped_config = cp.Configuration( + deviceConfigurations=[encrypted_device], SudoPassCmd=sudo_pass_cmd + ) + config_file = tmp_path / "config.json" + config_file.write_text(wrapped_config.model_dump_json()) + dest_dir = tmp_path / "mounts" + dest_dir.mkdir() + + mock_pipe = mocker.patch("shell_interface.pipe_pass_cmd_to_real_cmd") + mocker.patch( + "storage_device_managers.open_encrypted_device", + return_value=Path("/dev/mapper/test"), + ) + mocker.patch("storage_device_managers.mount_device") + + runner.invoke(app, ["open", str(dest_dir), "--config", str(config_file)]) + + mock_pipe.assert_called_once_with( + sudo_pass_cmd, ["sudo", "-Sv"], capture_output=True + ) + + +def test_sudo_pass_cmd_is_used_in_backup( + runner: CliRunner, + encrypted_device: cp.DeviceConfiguration, + mocker, + tmp_path: Path, +) -> None: + sudo_pass_cmd = "echo test_password" + wrapped_config = cp.Configuration( + deviceConfigurations=[encrypted_device], SudoPassCmd=sudo_pass_cmd + ) + config_file = tmp_path / "config.json" + config_file.write_text(wrapped_config.model_dump_json()) + + mock_pipe = mocker.patch("shell_interface.pipe_pass_cmd_to_real_cmd") + mocker.patch("storage_device_managers.decrypted_device") + mocker.patch("storage_device_managers.mounted_device") + mocker.patch( + "butter_backup.backup_backends.BackupBackend.from_config", + return_value=mocker.MagicMock(), + ) + + runner.invoke(app, ["backup", "--config", str(config_file)]) + + expected_nof_calls = 2 # One before opening the device and one post backup + result_calls = mock_pipe.call_args_list + expected_calls = [ + ((sudo_pass_cmd, ["sudo", "-Sv"]), {"capture_output": True}) + ] * expected_nof_calls + assert result_calls == expected_calls + + +def test_sudo_pass_cmd_is_used_in_close( + runner: CliRunner, + encrypted_device: cp.DeviceConfiguration, + mocker, + tmp_path: Path, +) -> None: + sudo_pass_cmd = "echo test_password" + wrapped_config = cp.Configuration( + deviceConfigurations=[encrypted_device], SudoPassCmd=sudo_pass_cmd + ) + config_file = tmp_path / "config.json" + config_file.write_text(wrapped_config.model_dump_json()) + map_name = str(encrypted_device.map_name()) + + mock_pipe = mocker.patch("shell_interface.pipe_pass_cmd_to_real_cmd") + mocker.patch( + "storage_device_managers.get_mounted_devices", + return_value={map_name: [tmp_path / "mnt"]}, + ) + mocker.patch("storage_device_managers.unmount_device") + mocker.patch("storage_device_managers.close_decrypted_device") + + runner.invoke(app, ["close", "--config", str(config_file)]) + + mock_pipe.assert_called_once_with( + sudo_pass_cmd, ["sudo", "-Sv"], capture_output=True + ) From 76430facea042ec9178845b3ff70aa2f0350e3ca Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 16:16:26 +0000 Subject: [PATCH 09/16] test: add privileged end-to-end tests for SudoPassCmd gated on env var Agent-Logs-Url: https://github.com/MaxG87/ButterBackup/sessions/c3eca6b4-12ee-4225-864d-0f6c4d8eda57 Co-authored-by: MaxG87 <5477952+MaxG87@users.noreply.github.com> --- tests/test_cli.py | 137 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) diff --git a/tests/test_cli.py b/tests/test_cli.py index 391b866..a5d3904 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,5 +1,7 @@ import datetime as dt +import os import re +import subprocess import time import typing as t from pathlib import Path @@ -22,6 +24,17 @@ def in_docker_container() -> bool: return Path("/.dockerenv").exists() +_SUDO_PASS_CMD = os.environ.get("BUTTERBACKUP_SUDO_PASSCMD") +_requires_sudo_pass_cmd = pytest.mark.skipif( + _SUDO_PASS_CMD is None, + reason="BUTTERBACKUP_SUDO_PASSCMD environment variable is not set", +) + + +def _invalidate_sudo_session() -> None: + subprocess.run(["sudo", "-k"], check=True) + + def prepare_tmp_path(config: cp.DeviceConfiguration, parent: Path) -> None: if isinstance(config, cp.BtrFSRsyncConfig): prepare_tmp_path_for_btrfs(config, parent) @@ -556,3 +569,127 @@ def test_sudo_pass_cmd_is_used_in_close( mock_pipe.assert_called_once_with( sudo_pass_cmd, ["sudo", "-Sv"], capture_output=True ) + + +@_requires_sudo_pass_cmd +@pytest.mark.skipif( + in_docker_container(), reason="Test is known to fail in Docker container" +) +def test_open_requires_correct_sudo_pass_cmd( + runner: CliRunner, + encrypted_device: cp.DeviceConfiguration, + tmp_path: Path, +) -> None: + assert _SUDO_PASS_CMD is not None + config = encrypted_device + dest_dir = tmp_path / "mounts" + dest_dir.mkdir() + correct_config = cp.Configuration( + deviceConfigurations=[config], SudoPassCmd=_SUDO_PASS_CMD + ) + correct_config_file = tmp_path / "correct_config.json" + correct_config_file.write_text(correct_config.model_dump_json()) + wrong_config = cp.Configuration(deviceConfigurations=[config], SudoPassCmd="false") + wrong_config_file = tmp_path / "wrong_config.json" + wrong_config_file.write_text(wrong_config.model_dump_json()) + + # Wrong pass-cmd: pipe_pass_cmd_to_real_cmd raises PassCmdError since "false" + # exits 1. The open command catches it and prints the failure message. + _invalidate_sudo_session() + wrong_result = runner.invoke( + app, ["open", str(dest_dir), "--config", str(wrong_config_file)] + ) + expected_fail_msg = f"Speichermedium {config.Name} konnte nicht geöffnet werden." + assert expected_fail_msg in wrong_result.stdout + + # Correct password: open should succeed + _invalidate_sudo_session() + correct_result = runner.invoke( + app, ["open", str(dest_dir), "--config", str(correct_config_file)] + ) + assert correct_result.exit_code == 0 + assert f"Speichermedium {config.Name} wurde in" in correct_result.stdout + + # Cleanup + runner.invoke(app, ["close", "--config", str(correct_config_file)]) + + +@_requires_sudo_pass_cmd +@pytest.mark.skipif( + in_docker_container(), reason="Test is known to fail in Docker container" +) +def test_backup_requires_correct_sudo_pass_cmd( + runner: CliRunner, + encrypted_device: cp.DeviceConfiguration, + tmp_path: Path, +) -> None: + assert _SUDO_PASS_CMD is not None + config = complement_configuration(encrypted_device, tmp_path) + prepare_tmp_path(config, tmp_path) + correct_config = cp.Configuration( + deviceConfigurations=[config], SudoPassCmd=_SUDO_PASS_CMD + ) + correct_config_file = tmp_path / "correct_config.json" + correct_config_file.write_text(correct_config.model_dump_json()) + wrong_config = cp.Configuration(deviceConfigurations=[config], SudoPassCmd="false") + wrong_config_file = tmp_path / "wrong_config.json" + wrong_config_file.write_text(wrong_config.model_dump_json()) + + # Wrong pass-cmd: pipe_pass_cmd_to_real_cmd raises PassCmdError since "false" + # exits 1. This propagates out of the backup command, producing a non-zero exit. + _invalidate_sudo_session() + wrong_result = runner.invoke(app, ["backup", "--config", str(wrong_config_file)]) + assert wrong_result.exit_code != 0 + + # Correct password: backup should succeed + _invalidate_sudo_session() + correct_result = runner.invoke( + app, ["backup", "--config", str(correct_config_file)] + ) + assert correct_result.exit_code == 0 + + +@_requires_sudo_pass_cmd +@pytest.mark.skipif( + in_docker_container(), reason="Test is known to fail in Docker container" +) +def test_close_requires_correct_sudo_pass_cmd( + runner: CliRunner, + encrypted_device: cp.DeviceConfiguration, + tmp_path: Path, +) -> None: + assert _SUDO_PASS_CMD is not None + config = encrypted_device + correct_config = cp.Configuration( + deviceConfigurations=[config], SudoPassCmd=_SUDO_PASS_CMD + ) + correct_config_file = tmp_path / "correct_config.json" + correct_config_file.write_text(correct_config.model_dump_json()) + wrong_config = cp.Configuration(deviceConfigurations=[config], SudoPassCmd="false") + wrong_config_file = tmp_path / "wrong_config.json" + wrong_config_file.write_text(wrong_config.model_dump_json()) + + # Open the device (sudo will be authenticated via the correct SudoPassCmd) + dest_dir = tmp_path / "mounts" + dest_dir.mkdir() + _invalidate_sudo_session() + open_result = runner.invoke( + app, ["open", str(dest_dir), "--config", str(correct_config_file)] + ) + # Confirm the device is actually open before testing close behavior. + # exit_code is always 0 for `open` (it swallows exceptions), so check the + # success message instead. + assert f"Speichermedium {config.Name} wurde in" in open_result.stdout + + # Wrong pass-cmd: pipe_pass_cmd_to_real_cmd raises PassCmdError since "false" + # exits 1. This propagates out of the close command, producing a non-zero exit. + _invalidate_sudo_session() + wrong_result = runner.invoke(app, ["close", "--config", str(wrong_config_file)]) + assert wrong_result.exit_code != 0 + assert config.map_name().exists() + + # Correct password: close should succeed + _invalidate_sudo_session() + correct_result = runner.invoke(app, ["close", "--config", str(correct_config_file)]) + assert correct_result.exit_code == 0 + assert not config.map_name().exists() From 494bb9129959eaa8582eb3d349f0f8863f4e2cd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20G=C3=B6rner?= <5477952+MaxG87@users.noreply.github.com> Date: Thu, 14 May 2026 23:48:34 +0200 Subject: [PATCH 10/16] refactor(test): Move BtrfsRsync config strategies to hypothesis_utils While here, two explicit composite strategies were rewritten to be just strategies. --- tests/config_parser/test_btrfs_config.py | 24 ++--------- tests/hypothesis_utils.py | 55 ++++++++++++++++++------ 2 files changed, 45 insertions(+), 34 deletions(-) diff --git a/tests/config_parser/test_btrfs_config.py b/tests/config_parser/test_btrfs_config.py index 1ee7971..b9492fe 100644 --- a/tests/config_parser/test_btrfs_config.py +++ b/tests/config_parser/test_btrfs_config.py @@ -1,4 +1,5 @@ import re +import typing as t from pathlib import Path from tempfile import NamedTemporaryFile, TemporaryDirectory @@ -15,25 +16,8 @@ EXCLUDE_FILE = TEST_RESOURCES / "exclude-file" -@st.composite -def valid_unparsed_empty_btrfs_config(draw): - config = draw( - st.fixed_dictionaries( - { - "BackupRepositoryFolder": st.text(), - "Compression": st.sampled_from( - [cur.value for cur in ValidCompressions] - ), - "ExcludePatternsFile": st.just(str(EXCLUDE_FILE)) | st.none(), - "DevicePassCmd": st.text(), - "Files": st.just([]), - "FilesDest": st.text(), - "Folders": st.just({}), - "UUID": st.uuids().map(str), - } - ) - ) - return config +def valid_unparsed_empty_btrfs_config() -> st.SearchStrategy[dict[str, t.Any]]: + return hu.valid_unparsed_empty_btrfs_config(EXCLUDE_FILE) @given(base_config=valid_unparsed_empty_btrfs_config(), dest_dir=hu.filenames()) @@ -51,7 +35,7 @@ def test_btrfs_config_rejects_file_dest_collision(base_config, dest_dir: str): @given(base_config=valid_unparsed_empty_btrfs_config(), file_name=hu.filenames()) -def test_btrfs_config_rejects_filename_collision(base_config, file_name): +def test_btrfs_config_rejects_filename_collision(base_config, file_name: str): base_config["Folders"] = {} with TemporaryDirectory() as td1: with TemporaryDirectory() as td2: diff --git a/tests/hypothesis_utils.py b/tests/hypothesis_utils.py index 9b45dcf..0b1bddc 100644 --- a/tests/hypothesis_utils.py +++ b/tests/hypothesis_utils.py @@ -1,25 +1,52 @@ +import typing as t +from pathlib import Path + from hypothesis import strategies as st +from storage_device_managers import ValidCompressions + +from butter_backup import config_parser as cp -@st.composite -def filenames(draw, min_size=1) -> str: +def filenames(min_size=1) -> st.SearchStrategy[str]: alpha = "abcdefghijklmnopqrstuvwxyzäöu" num = "01234567890" special = "_-.,() " permitted_chars = f"{alpha}{alpha.upper()}{num}{special}" - fname: str = draw( - st.text(permitted_chars, min_size=min_size).filter( - lambda fname: fname not in {".", ".."} - ) + return st.text(permitted_chars, min_size=min_size).filter( + lambda fname: fname not in {".", ".."} + ) + + +def valid_path_components(min_size=1) -> st.SearchStrategy[str]: + return st.text(min_size=min_size, max_size=128).filter( + lambda n: "/" not in n and "\x00" not in n and n not in {".", ".."} ) - return fname -@st.composite -def valid_path_components(draw, min_size=1) -> str: - name: str = draw( - st.text(min_size=min_size, max_size=128).filter( - lambda n: "/" not in n and "\x00" not in n and n not in {".", ".."} - ) +def valid_unparsed_empty_btrfs_config( + exclude_file: Path | None, +) -> st.SearchStrategy[dict[str, t.Any]]: + return valid_empty_btrfs_config(exclude_file).map( + lambda config: config.model_dump(mode="json") ) - return name + + +def valid_empty_btrfs_config( + exclude_file: Path | None, +) -> st.SearchStrategy[cp.BtrFSRsyncConfig]: + return st.builds( + cp.BtrFSRsyncConfig, + Name=st.none() | valid_path_components(), + BackupRepositoryFolder=valid_path_components(), + Compression=valid_compressions(), + ExcludePatternsFile=st.just(exclude_file), + DevicePassCmd=st.text(), + Files=st.just([]), + FilesDest=st.text(), + Folders=st.just({}), + UUID=st.uuids().map(str), + ) + + +def valid_compressions() -> st.SearchStrategy[ValidCompressions]: + return st.sampled_from(list(ValidCompressions)) From a0d04c7149edbe86c377711969cf82c9653952b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20G=C3=B6rner?= <5477952+MaxG87@users.noreply.github.com> Date: Fri, 15 May 2026 00:43:08 +0200 Subject: [PATCH 11/16] refactor(tests): Move Restic config strategy to hypothesis_utils --- tests/config_parser/test_restic_config.py | 20 ++++---------------- tests/hypothesis_utils.py | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+), 16 deletions(-) diff --git a/tests/config_parser/test_restic_config.py b/tests/config_parser/test_restic_config.py index e5b5faa..4f93a97 100644 --- a/tests/config_parser/test_restic_config.py +++ b/tests/config_parser/test_restic_config.py @@ -1,4 +1,3 @@ -import json from pathlib import Path from tempfile import NamedTemporaryFile, TemporaryDirectory @@ -15,21 +14,10 @@ EXCLUDE_FILE = TEST_RESOURCES / "exclude-file" -@st.composite -def valid_unparsed_empty_restic_config(draw): - config = draw( - st.builds( - cp.ResticConfig, - BackupRepositoryFolder=st.text(), - ExcludePatternsFile=st.just(str(EXCLUDE_FILE)) | st.none(), - DevicePassCmd=st.text(), - FilesAndFolders=st.just([]), - Name=hu.valid_path_components(), - RepositoryPassCmd=st.text(), - UUID=st.uuids(), - ) - ) - return json.loads(config.model_dump_json()) +def valid_unparsed_empty_restic_config() -> st.SearchStrategy[ + dict[str, str | Path | None] +]: + return hu.valid_unparsed_empty_restic_config(EXCLUDE_FILE) @given(base_config=valid_unparsed_empty_restic_config()) diff --git a/tests/hypothesis_utils.py b/tests/hypothesis_utils.py index 0b1bddc..dbdcb1b 100644 --- a/tests/hypothesis_utils.py +++ b/tests/hypothesis_utils.py @@ -50,3 +50,26 @@ def valid_empty_btrfs_config( def valid_compressions() -> st.SearchStrategy[ValidCompressions]: return st.sampled_from(list(ValidCompressions)) + + +def valid_empty_restic_config( + exclude_file: Path | None, +) -> st.SearchStrategy[cp.ResticConfig]: + return st.builds( + cp.ResticConfig, + BackupRepositoryFolder=st.text(), + ExcludePatternsFile=st.just(exclude_file), + DevicePassCmd=st.text(), + FilesAndFolders=st.just([]), + Name=st.none() | valid_path_components(), + RepositoryPassCmd=st.text(), + UUID=st.uuids(), + ) + + +def valid_unparsed_empty_restic_config( + exclude_file: Path | None, +) -> st.SearchStrategy[dict[str, t.Any]]: + return valid_empty_restic_config(exclude_file).map( + lambda config: config.model_dump(mode="json") + ) From ccf473ca594123903774b3281c604bcd1fd5d3af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20G=C3=B6rner?= <5477952+MaxG87@users.noreply.github.com> Date: Fri, 15 May 2026 01:10:46 +0200 Subject: [PATCH 12/16] refactor(test): Reuse new config strategies I --- .../config_parser/test_parse_configuration.py | 157 ++++++------------ 1 file changed, 51 insertions(+), 106 deletions(-) diff --git a/tests/config_parser/test_parse_configuration.py b/tests/config_parser/test_parse_configuration.py index 7eb9ac2..7a41722 100644 --- a/tests/config_parser/test_parse_configuration.py +++ b/tests/config_parser/test_parse_configuration.py @@ -1,4 +1,5 @@ import json +import typing as t from pathlib import Path from tempfile import TemporaryDirectory from uuid import UUID @@ -33,78 +34,53 @@ def test_example_files_can_be_parsed(example_file: Path) -> None: @given( - backup_dest_dirs=st.lists(st.text(), min_size=2, max_size=2, unique=True), - backup_repository_folder=st.text(), - name=hu.valid_path_components(), - pass_cmd=st.text(), - uuid=st.uuids(), + first_config=hu.valid_empty_btrfs_config(None), + second_config=hu.valid_empty_btrfs_config(None), + shared_name=hu.valid_path_components(), ) def test_parse_configuration_rejects_duplicate_names_for_btrfs_rsync( - backup_dest_dirs: list[str], - backup_repository_folder: str, - name: str, - pass_cmd: str, - uuid: UUID, + first_config: cp.BtrFSRsyncConfig, + second_config: cp.BtrFSRsyncConfig, + shared_name: str, ) -> None: - with TemporaryDirectory() as source: - btrfs_cfg = cp.BtrFSRsyncConfig( - BackupRepositoryFolder=backup_repository_folder, - DevicePassCmd=pass_cmd, - Files=set(), - FilesDest=backup_dest_dirs[1], - Folders={Path(source): backup_dest_dirs[0]}, - Name=name, - UUID=uuid, - ) - btrfs_cfg_json = btrfs_cfg.model_dump_json() - with pytest.raises(ValidationError): - cp.parse_configuration( - json.dumps( - { - "deviceConfigurations": [ - json.loads(btrfs_cfg_json), - json.loads(btrfs_cfg_json), - ] - } - ) + first_config = first_config.model_copy(update={"Name": shared_name}) + second_config = second_config.model_copy(update={"Name": shared_name}) + with pytest.raises(ValidationError): + cp.parse_configuration( + json.dumps( + { + "deviceConfigurations": [ + first_config.model_dump(mode="json"), + second_config.model_dump(mode="json"), + ] + } ) + ) @given( - backup_repository_folder=st.text(), - device_pass_cmd=st.text(), - name=hu.valid_path_components(), - repository_pass_cmd=st.text(), - uuid=st.uuids(), + first_config=hu.valid_empty_restic_config(None), + second_config=hu.valid_empty_restic_config(None), + shared_name=hu.valid_path_components(), ) def test_parse_configuration_rejects_duplicate_names_for_restic( - backup_repository_folder: str, - device_pass_cmd: str, - name: str, - repository_pass_cmd: str, - uuid: UUID, + first_config: cp.ResticConfig, + second_config: cp.ResticConfig, + shared_name: str, ) -> None: - with TemporaryDirectory() as source: - restic_cfg = cp.ResticConfig( - BackupRepositoryFolder=backup_repository_folder, - DevicePassCmd=device_pass_cmd, - FilesAndFolders={Path(source)}, - Name=name, - RepositoryPassCmd=repository_pass_cmd, - UUID=uuid, - ) - restic_cfg_json = restic_cfg.model_dump_json() - with pytest.raises(ValidationError): - cp.parse_configuration( - json.dumps( - { - "deviceConfigurations": [ - json.loads(restic_cfg_json), - json.loads(restic_cfg_json), - ] - } - ) + first_config = first_config.model_copy(update={"Name": shared_name}) + second_config = second_config.model_copy(update={"Name": shared_name}) + with pytest.raises(ValidationError): + cp.parse_configuration( + json.dumps( + { + "deviceConfigurations": [ + first_config.model_dump(mode="json"), + second_config.model_dump(mode="json"), + ] + } ) + ) def test_parse_configuration_rejects_empty_list() -> None: @@ -129,64 +105,33 @@ def test_parse_configuration_warns_on_non_dict_item() -> None: @given( - backup_dest_dirs=st.lists(st.text(), min_size=2, max_size=2, unique=True), - backup_repository_folder=st.text(), - name=hu.valid_path_components(), - pass_cmd=st.text(), - uuid=st.uuids(), + base_config=hu.valid_unparsed_empty_btrfs_config(None), + files_folders_dest=st.lists( + hu.valid_path_components(), min_size=2, max_size=2, unique=True + ), ) def test_parse_configuration_parses_btrfs_config( - backup_dest_dirs: list[str], - backup_repository_folder: str, - name: str, - pass_cmd: str, - uuid: UUID, + base_config: dict[str, t.Any], files_folders_dest: list[str] ) -> None: + files_dest, folders_dest = files_folders_dest with TemporaryDirectory() as source: - btrfs_cfg = cp.BtrFSRsyncConfig( - BackupRepositoryFolder=backup_repository_folder, - DevicePassCmd=pass_cmd, - Files=set(), - FilesDest=backup_dest_dirs[1], - Folders={Path(source): backup_dest_dirs[0]}, - Name=name, - UUID=uuid, - ) + base_config.update({"Folders": {source: folders_dest}, "FilesDest": files_dest}) + btrfs_cfg = cp.BtrFSRsyncConfig.model_validate(base_config) cfg_lst = cp.parse_configuration( - json.dumps( - {"deviceConfigurations": [json.loads(btrfs_cfg.model_dump_json())]} - ) + json.dumps({"deviceConfigurations": [btrfs_cfg.model_dump(mode="json")]}) ) assert cfg_lst.deviceConfigurations == [btrfs_cfg] @given( - backup_repository_folder=st.text(), - device_pass_cmd=st.text(), - name=hu.valid_path_components(), - repository_pass_cmd=st.text(), - uuid=st.uuids(), + base_config=hu.valid_unparsed_empty_restic_config(None), ) -def test_load_configuration_parses_restic_config( - backup_repository_folder: str, - device_pass_cmd: str, - name: str, - repository_pass_cmd: str, - uuid: UUID, -) -> None: +def test_load_configuration_parses_restic_config(base_config: dict[str, t.Any]) -> None: with TemporaryDirectory() as source: - restic_cfg = cp.ResticConfig( - BackupRepositoryFolder=backup_repository_folder, - DevicePassCmd=device_pass_cmd, - FilesAndFolders={Path(source)}, - Name=name, - RepositoryPassCmd=repository_pass_cmd, - UUID=uuid, - ) + base_config["FilesAndFolders"] = [source] + restic_cfg = cp.ResticConfig.model_validate(base_config) cfg_lst = cp.parse_configuration( - json.dumps( - {"deviceConfigurations": [json.loads(restic_cfg.model_dump_json())]} - ) + json.dumps({"deviceConfigurations": [restic_cfg.model_dump(mode="json")]}) ) assert cfg_lst.deviceConfigurations == [restic_cfg] From c531fd01c8abdb5ce8dd91d31c252cb5b1215377 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20G=C3=B6rner?= <5477952+MaxG87@users.noreply.github.com> Date: Fri, 15 May 2026 01:27:21 +0200 Subject: [PATCH 13/16] refactor(test): Reuse new config strategies II --- .../config_parser/test_parse_configuration.py | 116 ++++-------------- 1 file changed, 25 insertions(+), 91 deletions(-) diff --git a/tests/config_parser/test_parse_configuration.py b/tests/config_parser/test_parse_configuration.py index 7a41722..6f9149f 100644 --- a/tests/config_parser/test_parse_configuration.py +++ b/tests/config_parser/test_parse_configuration.py @@ -2,7 +2,6 @@ import typing as t from pathlib import Path from tempfile import TemporaryDirectory -from uuid import UUID import pytest from hypothesis import given @@ -34,38 +33,14 @@ def test_example_files_can_be_parsed(example_file: Path) -> None: @given( - first_config=hu.valid_empty_btrfs_config(None), - second_config=hu.valid_empty_btrfs_config(None), + first_config=hu.valid_empty_btrfs_config(None) | hu.valid_empty_restic_config(None), + second_config=hu.valid_empty_btrfs_config(None) + | hu.valid_empty_restic_config(None), shared_name=hu.valid_path_components(), ) -def test_parse_configuration_rejects_duplicate_names_for_btrfs_rsync( - first_config: cp.BtrFSRsyncConfig, - second_config: cp.BtrFSRsyncConfig, - shared_name: str, -) -> None: - first_config = first_config.model_copy(update={"Name": shared_name}) - second_config = second_config.model_copy(update={"Name": shared_name}) - with pytest.raises(ValidationError): - cp.parse_configuration( - json.dumps( - { - "deviceConfigurations": [ - first_config.model_dump(mode="json"), - second_config.model_dump(mode="json"), - ] - } - ) - ) - - -@given( - first_config=hu.valid_empty_restic_config(None), - second_config=hu.valid_empty_restic_config(None), - shared_name=hu.valid_path_components(), -) -def test_parse_configuration_rejects_duplicate_names_for_restic( - first_config: cp.ResticConfig, - second_config: cp.ResticConfig, +def test_parse_configuration_rejects_duplicate_names( + first_config: cp.BtrFSRsyncConfig | cp.ResticConfig, + second_config: cp.BtrFSRsyncConfig | cp.ResticConfig, shared_name: str, ) -> None: first_config = first_config.model_copy(update={"Name": shared_name}) @@ -117,10 +92,9 @@ def test_parse_configuration_parses_btrfs_config( with TemporaryDirectory() as source: base_config.update({"Folders": {source: folders_dest}, "FilesDest": files_dest}) btrfs_cfg = cp.BtrFSRsyncConfig.model_validate(base_config) - cfg_lst = cp.parse_configuration( - json.dumps({"deviceConfigurations": [btrfs_cfg.model_dump(mode="json")]}) - ) - assert cfg_lst.deviceConfigurations == [btrfs_cfg] + cfg = cp.Configuration(deviceConfigurations=[btrfs_cfg]) + result = cp.parse_configuration(cfg.model_dump_json()) + assert result == cfg @given( @@ -130,67 +104,27 @@ def test_load_configuration_parses_restic_config(base_config: dict[str, t.Any]) with TemporaryDirectory() as source: base_config["FilesAndFolders"] = [source] restic_cfg = cp.ResticConfig.model_validate(base_config) - cfg_lst = cp.parse_configuration( - json.dumps({"deviceConfigurations": [restic_cfg.model_dump(mode="json")]}) - ) - assert cfg_lst.deviceConfigurations == [restic_cfg] + cfg = cp.Configuration(deviceConfigurations=[restic_cfg]) + result = cp.parse_configuration(cfg.model_dump_json()) + assert result == cfg @given( - backup_repository_folder=st.text(), - device_pass_cmd=st.text(), - name=hu.valid_path_components(), - repository_pass_cmd=st.text(), + device_configurations=st.lists( + hu.valid_empty_restic_config(None) | hu.valid_empty_btrfs_config(None), + min_size=1, + unique_by=lambda cfg: cfg.Name, + ), sudo_pass_cmd=st.text(), - uuid=st.uuids(), ) -def test_parse_configuration_preserves_sudo_pass_cmd( # noqa: PLR0913 - backup_repository_folder: str, - device_pass_cmd: str, - name: str, - repository_pass_cmd: str, +def test_parse_configuration_with_sudo_pass_cmd( + device_configurations: list[cp.ResticConfig | cp.BtrFSRsyncConfig], sudo_pass_cmd: str, - uuid: UUID, -) -> None: - restic_cfg = cp.ResticConfig( - BackupRepositoryFolder=backup_repository_folder, - DevicePassCmd=device_pass_cmd, - FilesAndFolders={Path("/tmp")}, - Name=name, - RepositoryPassCmd=repository_pass_cmd, - UUID=uuid, - ) - raw = json.loads(restic_cfg.model_dump_json()) - config_json = json.dumps( - {"deviceConfigurations": [raw], "SudoPassCmd": sudo_pass_cmd} - ) - result = cp.parse_configuration(config_json) - assert result.SudoPassCmd == sudo_pass_cmd - - -@given( - backup_repository_folder=st.text(), - device_pass_cmd=st.text(), - name=hu.valid_path_components(), - repository_pass_cmd=st.text(), - uuid=st.uuids(), -) -def test_parse_configuration_defaults_sudo_pass_cmd_to_none( - backup_repository_folder: str, - device_pass_cmd: str, - name: str, - repository_pass_cmd: str, - uuid: UUID, ) -> None: - restic_cfg = cp.ResticConfig( - BackupRepositoryFolder=backup_repository_folder, - DevicePassCmd=device_pass_cmd, - FilesAndFolders={Path("/tmp")}, - Name=name, - RepositoryPassCmd=repository_pass_cmd, - UUID=uuid, + cfg = cp.Configuration( + deviceConfigurations=device_configurations, + SudoPassCmd=sudo_pass_cmd, ) - raw = json.loads(restic_cfg.model_dump_json()) - config_json = json.dumps({"deviceConfigurations": [raw]}) - result = cp.parse_configuration(config_json) - assert result.SudoPassCmd is None + raw = cfg.model_dump_json() + result = cp.parse_configuration(raw) + assert result == cfg From f320709f6ddc63893504516cb6b3bcfb17cd2195 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20G=C3=B6rner?= <5477952+MaxG87@users.noreply.github.com> Date: Fri, 15 May 2026 01:47:09 +0200 Subject: [PATCH 14/16] refactor: Split CLI tests into two files The file got way too long. All tests regarding sudo_pass_cmd are extracted into their own file for now. --- tests/cli/__init__.py | 35 +++ .../{test_cli.py => cli/test_cli_commands.py} | 258 +----------------- tests/cli/test_sudo_pass_cmd.py | 240 ++++++++++++++++ 3 files changed, 276 insertions(+), 257 deletions(-) create mode 100644 tests/cli/__init__.py rename tests/{test_cli.py => cli/test_cli_commands.py} (63%) create mode 100644 tests/cli/test_sudo_pass_cmd.py diff --git a/tests/cli/__init__.py b/tests/cli/__init__.py new file mode 100644 index 0000000..e303a3f --- /dev/null +++ b/tests/cli/__init__.py @@ -0,0 +1,35 @@ +import typing as t +from pathlib import Path + +from butter_backup import config_parser as cp + + +def in_docker_container() -> bool: + return Path("/.dockerenv").exists() + + +def prepare_tmp_path(config: cp.DeviceConfiguration, 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: + t.assert_never(config) + + +def _prepare_tmp_path_for_btrfs(config: cp.BtrFSRsyncConfig, parent: Path) -> None: + for cur in config.Folders: + cur.mkdir(exist_ok=True) + for cur in config.Files: + cur.parent.mkdir(exist_ok=True, parents=True) + cur.touch() + (parent / config.FilesDest).mkdir(exist_ok=True) + + +def _prepare_tmp_path_for_restic(config: cp.ResticConfig) -> None: + for cur in config.FilesAndFolders: + # For the purpose of the test that uses this helper function, + # test_do_backup_refuses_backup_when_device_is_already_open, it does not matter + # what the content of tmp_path is as long as the configuration can be parsed. + # Therefore, all items will be folders, since this is much easier to achieve. + cur.mkdir(parents=True, exist_ok=True) diff --git a/tests/test_cli.py b/tests/cli/test_cli_commands.py similarity index 63% rename from tests/test_cli.py rename to tests/cli/test_cli_commands.py index a5d3904..e65d055 100644 --- a/tests/test_cli.py +++ b/tests/cli/test_cli_commands.py @@ -1,9 +1,6 @@ import datetime as dt -import os import re -import subprocess import time -import typing as t from pathlib import Path from tempfile import NamedTemporaryFile, TemporaryDirectory from unittest import mock @@ -19,47 +16,7 @@ from butter_backup.cli import app from tests import complement_configuration, get_random_filename - -def in_docker_container() -> bool: - return Path("/.dockerenv").exists() - - -_SUDO_PASS_CMD = os.environ.get("BUTTERBACKUP_SUDO_PASSCMD") -_requires_sudo_pass_cmd = pytest.mark.skipif( - _SUDO_PASS_CMD is None, - reason="BUTTERBACKUP_SUDO_PASSCMD environment variable is not set", -) - - -def _invalidate_sudo_session() -> None: - subprocess.run(["sudo", "-k"], check=True) - - -def prepare_tmp_path(config: cp.DeviceConfiguration, 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: - t.assert_never(config) - - -def prepare_tmp_path_for_btrfs(config: cp.BtrFSRsyncConfig, parent: Path) -> None: - for cur in config.Folders: - cur.mkdir(exist_ok=True) - for cur in config.Files: - cur.parent.mkdir(exist_ok=True, parents=True) - cur.touch() - (parent / config.FilesDest).mkdir(exist_ok=True) - - -def prepare_tmp_path_for_restic(config: cp.ResticConfig) -> None: - for cur in config.FilesAndFolders: - # For the purpose of the test that uses this helper function, - # test_do_backup_refuses_backup_when_device_is_already_open, it does not matter - # what the content of tmp_path is as long as the configuration can be parsed. - # Therefore, all items will be folders, since this is much easier to achieve. - cur.mkdir(parents=True, exist_ok=True) +from . import in_docker_container, prepare_tmp_path def wait_until_gone(p: Path, timeout: dt.timedelta = dt.timedelta(seconds=3)) -> None: @@ -480,216 +437,3 @@ def test_unmount_error_does_not_cause_content_deletion( assert result.exit_code == 0 assert mount_of_device.exists() # Target directory should be kept after closing. assert sdm.is_mounted(mount_of_device) is False - - -def test_sudo_pass_cmd_is_used_in_open( - runner: CliRunner, - encrypted_device: cp.DeviceConfiguration, - mocker, - tmp_path: Path, -) -> None: - sudo_pass_cmd = "echo test_password" - wrapped_config = cp.Configuration( - deviceConfigurations=[encrypted_device], SudoPassCmd=sudo_pass_cmd - ) - config_file = tmp_path / "config.json" - config_file.write_text(wrapped_config.model_dump_json()) - dest_dir = tmp_path / "mounts" - dest_dir.mkdir() - - mock_pipe = mocker.patch("shell_interface.pipe_pass_cmd_to_real_cmd") - mocker.patch( - "storage_device_managers.open_encrypted_device", - return_value=Path("/dev/mapper/test"), - ) - mocker.patch("storage_device_managers.mount_device") - - runner.invoke(app, ["open", str(dest_dir), "--config", str(config_file)]) - - mock_pipe.assert_called_once_with( - sudo_pass_cmd, ["sudo", "-Sv"], capture_output=True - ) - - -def test_sudo_pass_cmd_is_used_in_backup( - runner: CliRunner, - encrypted_device: cp.DeviceConfiguration, - mocker, - tmp_path: Path, -) -> None: - sudo_pass_cmd = "echo test_password" - wrapped_config = cp.Configuration( - deviceConfigurations=[encrypted_device], SudoPassCmd=sudo_pass_cmd - ) - config_file = tmp_path / "config.json" - config_file.write_text(wrapped_config.model_dump_json()) - - mock_pipe = mocker.patch("shell_interface.pipe_pass_cmd_to_real_cmd") - mocker.patch("storage_device_managers.decrypted_device") - mocker.patch("storage_device_managers.mounted_device") - mocker.patch( - "butter_backup.backup_backends.BackupBackend.from_config", - return_value=mocker.MagicMock(), - ) - - runner.invoke(app, ["backup", "--config", str(config_file)]) - - expected_nof_calls = 2 # One before opening the device and one post backup - result_calls = mock_pipe.call_args_list - expected_calls = [ - ((sudo_pass_cmd, ["sudo", "-Sv"]), {"capture_output": True}) - ] * expected_nof_calls - assert result_calls == expected_calls - - -def test_sudo_pass_cmd_is_used_in_close( - runner: CliRunner, - encrypted_device: cp.DeviceConfiguration, - mocker, - tmp_path: Path, -) -> None: - sudo_pass_cmd = "echo test_password" - wrapped_config = cp.Configuration( - deviceConfigurations=[encrypted_device], SudoPassCmd=sudo_pass_cmd - ) - config_file = tmp_path / "config.json" - config_file.write_text(wrapped_config.model_dump_json()) - map_name = str(encrypted_device.map_name()) - - mock_pipe = mocker.patch("shell_interface.pipe_pass_cmd_to_real_cmd") - mocker.patch( - "storage_device_managers.get_mounted_devices", - return_value={map_name: [tmp_path / "mnt"]}, - ) - mocker.patch("storage_device_managers.unmount_device") - mocker.patch("storage_device_managers.close_decrypted_device") - - runner.invoke(app, ["close", "--config", str(config_file)]) - - mock_pipe.assert_called_once_with( - sudo_pass_cmd, ["sudo", "-Sv"], capture_output=True - ) - - -@_requires_sudo_pass_cmd -@pytest.mark.skipif( - in_docker_container(), reason="Test is known to fail in Docker container" -) -def test_open_requires_correct_sudo_pass_cmd( - runner: CliRunner, - encrypted_device: cp.DeviceConfiguration, - tmp_path: Path, -) -> None: - assert _SUDO_PASS_CMD is not None - config = encrypted_device - dest_dir = tmp_path / "mounts" - dest_dir.mkdir() - correct_config = cp.Configuration( - deviceConfigurations=[config], SudoPassCmd=_SUDO_PASS_CMD - ) - correct_config_file = tmp_path / "correct_config.json" - correct_config_file.write_text(correct_config.model_dump_json()) - wrong_config = cp.Configuration(deviceConfigurations=[config], SudoPassCmd="false") - wrong_config_file = tmp_path / "wrong_config.json" - wrong_config_file.write_text(wrong_config.model_dump_json()) - - # Wrong pass-cmd: pipe_pass_cmd_to_real_cmd raises PassCmdError since "false" - # exits 1. The open command catches it and prints the failure message. - _invalidate_sudo_session() - wrong_result = runner.invoke( - app, ["open", str(dest_dir), "--config", str(wrong_config_file)] - ) - expected_fail_msg = f"Speichermedium {config.Name} konnte nicht geöffnet werden." - assert expected_fail_msg in wrong_result.stdout - - # Correct password: open should succeed - _invalidate_sudo_session() - correct_result = runner.invoke( - app, ["open", str(dest_dir), "--config", str(correct_config_file)] - ) - assert correct_result.exit_code == 0 - assert f"Speichermedium {config.Name} wurde in" in correct_result.stdout - - # Cleanup - runner.invoke(app, ["close", "--config", str(correct_config_file)]) - - -@_requires_sudo_pass_cmd -@pytest.mark.skipif( - in_docker_container(), reason="Test is known to fail in Docker container" -) -def test_backup_requires_correct_sudo_pass_cmd( - runner: CliRunner, - encrypted_device: cp.DeviceConfiguration, - tmp_path: Path, -) -> None: - assert _SUDO_PASS_CMD is not None - config = complement_configuration(encrypted_device, tmp_path) - prepare_tmp_path(config, tmp_path) - correct_config = cp.Configuration( - deviceConfigurations=[config], SudoPassCmd=_SUDO_PASS_CMD - ) - correct_config_file = tmp_path / "correct_config.json" - correct_config_file.write_text(correct_config.model_dump_json()) - wrong_config = cp.Configuration(deviceConfigurations=[config], SudoPassCmd="false") - wrong_config_file = tmp_path / "wrong_config.json" - wrong_config_file.write_text(wrong_config.model_dump_json()) - - # Wrong pass-cmd: pipe_pass_cmd_to_real_cmd raises PassCmdError since "false" - # exits 1. This propagates out of the backup command, producing a non-zero exit. - _invalidate_sudo_session() - wrong_result = runner.invoke(app, ["backup", "--config", str(wrong_config_file)]) - assert wrong_result.exit_code != 0 - - # Correct password: backup should succeed - _invalidate_sudo_session() - correct_result = runner.invoke( - app, ["backup", "--config", str(correct_config_file)] - ) - assert correct_result.exit_code == 0 - - -@_requires_sudo_pass_cmd -@pytest.mark.skipif( - in_docker_container(), reason="Test is known to fail in Docker container" -) -def test_close_requires_correct_sudo_pass_cmd( - runner: CliRunner, - encrypted_device: cp.DeviceConfiguration, - tmp_path: Path, -) -> None: - assert _SUDO_PASS_CMD is not None - config = encrypted_device - correct_config = cp.Configuration( - deviceConfigurations=[config], SudoPassCmd=_SUDO_PASS_CMD - ) - correct_config_file = tmp_path / "correct_config.json" - correct_config_file.write_text(correct_config.model_dump_json()) - wrong_config = cp.Configuration(deviceConfigurations=[config], SudoPassCmd="false") - wrong_config_file = tmp_path / "wrong_config.json" - wrong_config_file.write_text(wrong_config.model_dump_json()) - - # Open the device (sudo will be authenticated via the correct SudoPassCmd) - dest_dir = tmp_path / "mounts" - dest_dir.mkdir() - _invalidate_sudo_session() - open_result = runner.invoke( - app, ["open", str(dest_dir), "--config", str(correct_config_file)] - ) - # Confirm the device is actually open before testing close behavior. - # exit_code is always 0 for `open` (it swallows exceptions), so check the - # success message instead. - assert f"Speichermedium {config.Name} wurde in" in open_result.stdout - - # Wrong pass-cmd: pipe_pass_cmd_to_real_cmd raises PassCmdError since "false" - # exits 1. This propagates out of the close command, producing a non-zero exit. - _invalidate_sudo_session() - wrong_result = runner.invoke(app, ["close", "--config", str(wrong_config_file)]) - assert wrong_result.exit_code != 0 - assert config.map_name().exists() - - # Correct password: close should succeed - _invalidate_sudo_session() - correct_result = runner.invoke(app, ["close", "--config", str(correct_config_file)]) - assert correct_result.exit_code == 0 - assert not config.map_name().exists() diff --git a/tests/cli/test_sudo_pass_cmd.py b/tests/cli/test_sudo_pass_cmd.py new file mode 100644 index 0000000..1235171 --- /dev/null +++ b/tests/cli/test_sudo_pass_cmd.py @@ -0,0 +1,240 @@ +import os +from pathlib import Path + +import pytest +import shell_interface as sh +from typer.testing import CliRunner + +from butter_backup import config_parser as cp +from butter_backup.cli import app +from tests import complement_configuration + +from . import in_docker_container, prepare_tmp_path + +_SUDO_PASS_CMD = os.environ.get("BUTTERBACKUP_SUDO_PASSCMD") +_requires_sudo_pass_cmd = pytest.mark.skipif( + _SUDO_PASS_CMD is None, + reason="BUTTERBACKUP_SUDO_PASSCMD environment variable is not set", +) + + +@pytest.fixture +def runner(): + return CliRunner() + + +def _invalidate_sudo_session() -> None: + sh.run_cmd(cmd=["sudo", "-k"]) + + +def test_sudo_pass_cmd_is_used_in_open( + runner: CliRunner, + encrypted_device: cp.DeviceConfiguration, + mocker, + tmp_path: Path, +) -> None: + sudo_pass_cmd = "echo test_password" + wrapped_config = cp.Configuration( + deviceConfigurations=[encrypted_device], SudoPassCmd=sudo_pass_cmd + ) + config_file = tmp_path / "config.json" + config_file.write_text(wrapped_config.model_dump_json()) + dest_dir = tmp_path / "mounts" + dest_dir.mkdir() + + mock_pipe = mocker.patch("shell_interface.pipe_pass_cmd_to_real_cmd") + mocker.patch( + "storage_device_managers.open_encrypted_device", + return_value=Path("/dev/mapper/test"), + ) + mocker.patch("storage_device_managers.mount_device") + + runner.invoke(app, ["open", str(dest_dir), "--config", str(config_file)]) + + mock_pipe.assert_called_once_with( + sudo_pass_cmd, ["sudo", "-Sv"], capture_output=True + ) + + +def test_sudo_pass_cmd_is_used_in_backup( + runner: CliRunner, + encrypted_device: cp.DeviceConfiguration, + mocker, + tmp_path: Path, +) -> None: + sudo_pass_cmd = "echo test_password" + wrapped_config = cp.Configuration( + deviceConfigurations=[encrypted_device], SudoPassCmd=sudo_pass_cmd + ) + config_file = tmp_path / "config.json" + config_file.write_text(wrapped_config.model_dump_json()) + + mock_pipe = mocker.patch("shell_interface.pipe_pass_cmd_to_real_cmd") + mocker.patch("storage_device_managers.decrypted_device") + mocker.patch("storage_device_managers.mounted_device") + mocker.patch( + "butter_backup.backup_backends.BackupBackend.from_config", + return_value=mocker.MagicMock(), + ) + + runner.invoke(app, ["backup", "--config", str(config_file)]) + + expected_nof_calls = 2 # One before opening the device and one post backup + result_calls = mock_pipe.call_args_list + expected_calls = [ + ((sudo_pass_cmd, ["sudo", "-Sv"]), {"capture_output": True}) + ] * expected_nof_calls + assert result_calls == expected_calls + + +def test_sudo_pass_cmd_is_used_in_close( + runner: CliRunner, + encrypted_device: cp.DeviceConfiguration, + mocker, + tmp_path: Path, +) -> None: + sudo_pass_cmd = "echo test_password" + wrapped_config = cp.Configuration( + deviceConfigurations=[encrypted_device], SudoPassCmd=sudo_pass_cmd + ) + config_file = tmp_path / "config.json" + config_file.write_text(wrapped_config.model_dump_json()) + map_name = str(encrypted_device.map_name()) + + mock_pipe = mocker.patch("shell_interface.pipe_pass_cmd_to_real_cmd") + mocker.patch( + "storage_device_managers.get_mounted_devices", + return_value={map_name: [tmp_path / "mnt"]}, + ) + mocker.patch("storage_device_managers.unmount_device") + mocker.patch("storage_device_managers.close_decrypted_device") + + runner.invoke(app, ["close", "--config", str(config_file)]) + + mock_pipe.assert_called_once_with( + sudo_pass_cmd, ["sudo", "-Sv"], capture_output=True + ) + + +@_requires_sudo_pass_cmd +@pytest.mark.skipif( + in_docker_container(), reason="Test is known to fail in Docker container" +) +def test_open_requires_correct_sudo_pass_cmd( + runner: CliRunner, + encrypted_device: cp.DeviceConfiguration, + tmp_path: Path, +) -> None: + assert _SUDO_PASS_CMD is not None + config = encrypted_device + dest_dir = tmp_path / "mounts" + dest_dir.mkdir() + correct_config = cp.Configuration( + deviceConfigurations=[config], SudoPassCmd=_SUDO_PASS_CMD + ) + correct_config_file = tmp_path / "correct_config.json" + correct_config_file.write_text(correct_config.model_dump_json()) + wrong_config = cp.Configuration(deviceConfigurations=[config], SudoPassCmd="false") + wrong_config_file = tmp_path / "wrong_config.json" + wrong_config_file.write_text(wrong_config.model_dump_json()) + + # Wrong pass-cmd: pipe_pass_cmd_to_real_cmd raises PassCmdError since "false" + # exits 1. The open command catches it and prints the failure message. + _invalidate_sudo_session() + wrong_result = runner.invoke( + app, ["open", str(dest_dir), "--config", str(wrong_config_file)] + ) + expected_fail_msg = f"Speichermedium {config.Name} konnte nicht geöffnet werden." + assert expected_fail_msg in wrong_result.stdout + + # Correct password: open should succeed + _invalidate_sudo_session() + correct_result = runner.invoke( + app, ["open", str(dest_dir), "--config", str(correct_config_file)] + ) + assert correct_result.exit_code == 0 + assert f"Speichermedium {config.Name} wurde in" in correct_result.stdout + + # Cleanup + runner.invoke(app, ["close", "--config", str(correct_config_file)]) + + +@_requires_sudo_pass_cmd +@pytest.mark.skipif( + in_docker_container(), reason="Test is known to fail in Docker container" +) +def test_backup_requires_correct_sudo_pass_cmd( + runner: CliRunner, + encrypted_device: cp.DeviceConfiguration, + tmp_path: Path, +) -> None: + assert _SUDO_PASS_CMD is not None + config = complement_configuration(encrypted_device, tmp_path) + prepare_tmp_path(config, tmp_path) + correct_config = cp.Configuration( + deviceConfigurations=[config], SudoPassCmd=_SUDO_PASS_CMD + ) + correct_config_file = tmp_path / "correct_config.json" + correct_config_file.write_text(correct_config.model_dump_json()) + wrong_config = cp.Configuration(deviceConfigurations=[config], SudoPassCmd="false") + wrong_config_file = tmp_path / "wrong_config.json" + wrong_config_file.write_text(wrong_config.model_dump_json()) + + # Wrong pass-cmd: pipe_pass_cmd_to_real_cmd raises PassCmdError since "false" + # exits 1. This propagates out of the backup command, producing a non-zero exit. + _invalidate_sudo_session() + wrong_result = runner.invoke(app, ["backup", "--config", str(wrong_config_file)]) + assert wrong_result.exit_code != 0 + + # Correct password: backup should succeed + _invalidate_sudo_session() + correct_result = runner.invoke( + app, ["backup", "--config", str(correct_config_file)] + ) + assert correct_result.exit_code == 0 + + +@_requires_sudo_pass_cmd +@pytest.mark.skipif( + in_docker_container(), reason="Test is known to fail in Docker container" +) +def test_close_requires_correct_sudo_pass_cmd( + runner: CliRunner, + encrypted_device: cp.DeviceConfiguration, + tmp_path: Path, +) -> None: + assert _SUDO_PASS_CMD is not None + config = encrypted_device + correct_config = cp.Configuration( + deviceConfigurations=[config], SudoPassCmd=_SUDO_PASS_CMD + ) + correct_config_file = tmp_path / "correct_config.json" + correct_config_file.write_text(correct_config.model_dump_json()) + wrong_config = cp.Configuration(deviceConfigurations=[config], SudoPassCmd="false") + wrong_config_file = tmp_path / "wrong_config.json" + wrong_config_file.write_text(wrong_config.model_dump_json()) + + # Open the device (sudo will be authenticated via the correct SudoPassCmd) + dest_dir = tmp_path / "mounts" + dest_dir.mkdir() + _invalidate_sudo_session() + open_result = runner.invoke( + app, ["open", str(dest_dir), "--config", str(correct_config_file)] + ) + # Confirm the device is actually open before testing close behavior. + # exit_code is always 0 for `open` (it swallows exceptions), so check the + # success message instead. + assert f"Speichermedium {config.Name} wurde in" in open_result.stdout + + # Wrong pass-cmd: pipe_pass_cmd_to_real_cmd raises PassCmdError since "false" + # exits 1. This propagates out of the close command, producing a non-zero exit. + _invalidate_sudo_session() + wrong_result = runner.invoke(app, ["close", "--config", str(wrong_config_file)]) + assert wrong_result.exit_code != 0 + assert config.map_name().exists() + + # Correct password: close should succeed + _invalidate_sudo_session() + correct_result = runner.invoke(app, ["close", "--config", str(correct_config_file)]) + assert correct_result.exit_code == 0 + assert not config.map_name().exists() From e74c54a69c3a49eb3c4220feb5632613e4a9a8d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20G=C3=B6rner?= <5477952+MaxG87@users.noreply.github.com> Date: Fri, 15 May 2026 02:30:10 +0200 Subject: [PATCH 15/16] refactor: Deduplicate tests regarding real usage of SudoPassCmd --- tests/cli/test_sudo_pass_cmd.py | 121 ++++++++------------------------ 1 file changed, 28 insertions(+), 93 deletions(-) diff --git a/tests/cli/test_sudo_pass_cmd.py b/tests/cli/test_sudo_pass_cmd.py index 1235171..002496e 100644 --- a/tests/cli/test_sudo_pass_cmd.py +++ b/tests/cli/test_sudo_pass_cmd.py @@ -1,8 +1,10 @@ import os +import typing as t from pathlib import Path import pytest import shell_interface as sh +from click.testing import Result from typer.testing import CliRunner from butter_backup import config_parser as cp @@ -120,15 +122,30 @@ def test_sudo_pass_cmd_is_used_in_close( @pytest.mark.skipif( in_docker_container(), reason="Test is known to fail in Docker container" ) +@pytest.mark.parametrize( + "command,has_failed", + [ + ( + "open", + lambda config, result: ( + f"Speichermedium {config.Name} konnte nicht geöffnet werden." + in result.stdout + ), + ), + ("backup", lambda _, result: result.exit_code != 0), + ("close", lambda _, result: result.exit_code != 0), + ], +) def test_open_requires_correct_sudo_pass_cmd( runner: CliRunner, encrypted_device: cp.DeviceConfiguration, + command: str, + has_failed: t.Callable[[cp.DeviceConfiguration, Result], bool], tmp_path: Path, ) -> None: assert _SUDO_PASS_CMD is not None - config = encrypted_device - dest_dir = tmp_path / "mounts" - dest_dir.mkdir() + config = complement_configuration(encrypted_device, tmp_path) + prepare_tmp_path(config, tmp_path) correct_config = cp.Configuration( deviceConfigurations=[config], SudoPassCmd=_SUDO_PASS_CMD ) @@ -141,100 +158,18 @@ def test_open_requires_correct_sudo_pass_cmd( # Wrong pass-cmd: pipe_pass_cmd_to_real_cmd raises PassCmdError since "false" # exits 1. The open command catches it and prints the failure message. _invalidate_sudo_session() - wrong_result = runner.invoke( - app, ["open", str(dest_dir), "--config", str(wrong_config_file)] - ) - expected_fail_msg = f"Speichermedium {config.Name} konnte nicht geöffnet werden." - assert expected_fail_msg in wrong_result.stdout + if command == "close": + # The device needs to be open for the close command to pick up the device at + # all. + runner.invoke(app, ["open", "--config", str(correct_config_file)]) + + wrong_result = runner.invoke(app, [command, "--config", str(wrong_config_file)]) + assert has_failed(config, wrong_result) # Correct password: open should succeed _invalidate_sudo_session() - correct_result = runner.invoke( - app, ["open", str(dest_dir), "--config", str(correct_config_file)] - ) + correct_result = runner.invoke(app, [command, "--config", str(correct_config_file)]) assert correct_result.exit_code == 0 - assert f"Speichermedium {config.Name} wurde in" in correct_result.stdout # Cleanup runner.invoke(app, ["close", "--config", str(correct_config_file)]) - - -@_requires_sudo_pass_cmd -@pytest.mark.skipif( - in_docker_container(), reason="Test is known to fail in Docker container" -) -def test_backup_requires_correct_sudo_pass_cmd( - runner: CliRunner, - encrypted_device: cp.DeviceConfiguration, - tmp_path: Path, -) -> None: - assert _SUDO_PASS_CMD is not None - config = complement_configuration(encrypted_device, tmp_path) - prepare_tmp_path(config, tmp_path) - correct_config = cp.Configuration( - deviceConfigurations=[config], SudoPassCmd=_SUDO_PASS_CMD - ) - correct_config_file = tmp_path / "correct_config.json" - correct_config_file.write_text(correct_config.model_dump_json()) - wrong_config = cp.Configuration(deviceConfigurations=[config], SudoPassCmd="false") - wrong_config_file = tmp_path / "wrong_config.json" - wrong_config_file.write_text(wrong_config.model_dump_json()) - - # Wrong pass-cmd: pipe_pass_cmd_to_real_cmd raises PassCmdError since "false" - # exits 1. This propagates out of the backup command, producing a non-zero exit. - _invalidate_sudo_session() - wrong_result = runner.invoke(app, ["backup", "--config", str(wrong_config_file)]) - assert wrong_result.exit_code != 0 - - # Correct password: backup should succeed - _invalidate_sudo_session() - correct_result = runner.invoke( - app, ["backup", "--config", str(correct_config_file)] - ) - assert correct_result.exit_code == 0 - - -@_requires_sudo_pass_cmd -@pytest.mark.skipif( - in_docker_container(), reason="Test is known to fail in Docker container" -) -def test_close_requires_correct_sudo_pass_cmd( - runner: CliRunner, - encrypted_device: cp.DeviceConfiguration, - tmp_path: Path, -) -> None: - assert _SUDO_PASS_CMD is not None - config = encrypted_device - correct_config = cp.Configuration( - deviceConfigurations=[config], SudoPassCmd=_SUDO_PASS_CMD - ) - correct_config_file = tmp_path / "correct_config.json" - correct_config_file.write_text(correct_config.model_dump_json()) - wrong_config = cp.Configuration(deviceConfigurations=[config], SudoPassCmd="false") - wrong_config_file = tmp_path / "wrong_config.json" - wrong_config_file.write_text(wrong_config.model_dump_json()) - - # Open the device (sudo will be authenticated via the correct SudoPassCmd) - dest_dir = tmp_path / "mounts" - dest_dir.mkdir() - _invalidate_sudo_session() - open_result = runner.invoke( - app, ["open", str(dest_dir), "--config", str(correct_config_file)] - ) - # Confirm the device is actually open before testing close behavior. - # exit_code is always 0 for `open` (it swallows exceptions), so check the - # success message instead. - assert f"Speichermedium {config.Name} wurde in" in open_result.stdout - - # Wrong pass-cmd: pipe_pass_cmd_to_real_cmd raises PassCmdError since "false" - # exits 1. This propagates out of the close command, producing a non-zero exit. - _invalidate_sudo_session() - wrong_result = runner.invoke(app, ["close", "--config", str(wrong_config_file)]) - assert wrong_result.exit_code != 0 - assert config.map_name().exists() - - # Correct password: close should succeed - _invalidate_sudo_session() - correct_result = runner.invoke(app, ["close", "--config", str(correct_config_file)]) - assert correct_result.exit_code == 0 - assert not config.map_name().exists() From 15033b1bb6f304d69494e2a4a5660c022c1004b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20G=C3=B6rner?= <5477952+MaxG87@users.noreply.github.com> Date: Sat, 16 May 2026 11:07:22 +0200 Subject: [PATCH 16/16] style: Switch to uppercase DeviceConfigurations --- examples/json.cfg | 2 +- examples/json5.cfg | 2 +- examples/yaml.cfg | 2 +- src/butter_backup/cli.py | 8 ++++---- src/butter_backup/config_parser.py | 8 ++++---- tests/cli/test_cli_commands.py | 18 +++++++++--------- tests/cli/test_sudo_pass_cmd.py | 10 +++++----- .../config_parser/test_parse_configuration.py | 16 ++++++++-------- 8 files changed, 33 insertions(+), 33 deletions(-) diff --git a/examples/json.cfg b/examples/json.cfg index 0f1f113..908ddb0 100644 --- a/examples/json.cfg +++ b/examples/json.cfg @@ -1,6 +1,6 @@ { "SudoPassCmd": "gpg --decrypt ~/.local/share/passwords/sudo-password.gpg", - "deviceConfigurations": [ + "DeviceConfigurations": [ { "Name": "BtrFS Backup Example", "UUID": "12345678-1234-5678-1234-567812345678", diff --git a/examples/json5.cfg b/examples/json5.cfg index 143728c..f8961cf 100644 --- a/examples/json5.cfg +++ b/examples/json5.cfg @@ -1,7 +1,7 @@ // JSON5 allows comments and trailing commas { SudoPassCmd: "gpg --decrypt ~/.local/share/passwords/sudo-password.gpg", - deviceConfigurations: [ + DeviceConfigurations: [ { Name: "BtrFS Backup Example", UUID: "12345678-1234-5678-1234-567812345678", diff --git a/examples/yaml.cfg b/examples/yaml.cfg index ec489de..628ba57 100644 --- a/examples/yaml.cfg +++ b/examples/yaml.cfg @@ -1,6 +1,6 @@ --- SudoPassCmd: "gpg --decrypt ~/.local/share/passwords/sudo-password.gpg" -deviceConfigurations: +DeviceConfigurations: - Name: "BtrFS Backup Example" UUID: "12345678-1234-5678-1234-567812345678" DevicePassCmd: "echo device-password" diff --git a/src/butter_backup/cli.py b/src/butter_backup/cli.py index 1593568..0725dae 100644 --- a/src/butter_backup/cli.py +++ b/src/butter_backup/cli.py @@ -156,7 +156,7 @@ def open( # noqa: A001 setup_logging(verbose) parsed_config = cp.parse_configuration(config.read_text()) base_dir = dest if dest is not None else Path(mkdtemp()) - for cfg in parsed_config.deviceConfigurations: + for cfg in parsed_config.DeviceConfigurations: if _skip_device( cfg, log_opened=lambda cfg: logger.warning( @@ -179,7 +179,7 @@ def close(config: Path = CONFIG_OPTION, verbose: int = VERBOSITY_OPTION) -> None setup_logging(verbose) parsed_config = cp.parse_configuration(config.read_text()) mounted_devices = sdm.get_mounted_devices() - for cfg in parsed_config.deviceConfigurations: + for cfg in parsed_config.DeviceConfigurations: map_name = cfg.map_name() map_name_as_str = str(map_name) if cfg.device().exists() and map_name_as_str in mounted_devices: @@ -220,7 +220,7 @@ def backup(config: Path = CONFIG_OPTION, verbose: int = VERBOSITY_OPTION) -> Non """ setup_logging(verbose) parsed_config = cp.parse_configuration(config.read_text()) - for cfg in parsed_config.deviceConfigurations: + for cfg in parsed_config.DeviceConfigurations: if _skip_device( cfg, log_missing=lambda cfg: logger.info( @@ -304,7 +304,7 @@ def format_device( case _: t.assert_never(backend) json_serialisable = json.loads(config.model_dump_json(exclude_none=True)) - wrapper = {"deviceConfigurations": [json_serialisable]} + wrapper = {"DeviceConfigurations": [json_serialisable]} config_writer(json.dumps(wrapper, indent=4, sort_keys=True)) diff --git a/src/butter_backup/config_parser.py b/src/butter_backup/config_parser.py index 8bb130d..8b079ff 100644 --- a/src/butter_backup/config_parser.py +++ b/src/butter_backup/config_parser.py @@ -182,12 +182,12 @@ def compression(self) -> None: class Configuration(BaseModel): model_config = ConfigDict(frozen=True) - deviceConfigurations: list[DeviceConfiguration] + DeviceConfigurations: list[DeviceConfiguration] SudoPassCmd: str | None = None @model_validator(mode="after") def check_unique_names(self) -> t.Self: - name_counts = Counter(cfg.Name for cfg in self.deviceConfigurations) + name_counts = Counter(cfg.Name for cfg in self.DeviceConfigurations) duplicates = [name for name, count in name_counts.items() if count > 1] if duplicates: raise ValueError( @@ -207,7 +207,7 @@ def _parse_as_json5(content: str) -> Any: def _parse_as_toml(content: str) -> Any: data = tomllib.loads(content) bb = data["butter-backup"] - return {"deviceConfigurations": bb["device-configurations"]} + return {"DeviceConfigurations": bb["device-configurations"]} def _parse_as_yaml(content: str) -> Any: @@ -229,7 +229,7 @@ def parse_configuration(content: str) -> Configuration: except exc_type: continue config = Configuration.model_validate(raw) - if len(config.deviceConfigurations) == 0: + if len(config.DeviceConfigurations) == 0: sys.exit("Leere Konfigurationsdateien sind nicht erlaubt.\n") return config diff --git a/tests/cli/test_cli_commands.py b/tests/cli/test_cli_commands.py index e65d055..b2fe186 100644 --- a/tests/cli/test_cli_commands.py +++ b/tests/cli/test_cli_commands.py @@ -154,7 +154,7 @@ def test_close_does_not_close_unopened_device(runner, encrypted_btrfs_device) -> config = encrypted_btrfs_device with NamedTemporaryFile() as tempf: config_file = Path(tempf.name) - wrapped_config = cp.Configuration(deviceConfigurations=[config]) + wrapped_config = cp.Configuration(DeviceConfigurations=[config]) config_file.write_text(wrapped_config.model_dump_json()) close_result = runner.invoke(app, ["close", "--config", str(config_file)]) assert close_result.stdout == "" @@ -169,7 +169,7 @@ def test_open_close_roundtrip(runner, encrypted_device) -> None: expected_cryptsetup_map = Path(f"/dev/mapper/{config.UUID}") with NamedTemporaryFile() as tempf: config_file = Path(tempf.name) - wrapped_config = cp.Configuration(deviceConfigurations=[config]) + wrapped_config = cp.Configuration(DeviceConfigurations=[config]) config_file.write_text(wrapped_config.model_dump_json()) open_result = runner.invoke(app, ["open", "--config", str(config_file)]) expected_msg = ( @@ -199,7 +199,7 @@ def test_open_with_explicit_dest( config = encrypted_device expected_cryptsetup_map = Path(f"/dev/mapper/{config.UUID}") config_file = tmp_path / "config.json" - wrapped_config = cp.Configuration(deviceConfigurations=[config]) + wrapped_config = cp.Configuration(DeviceConfigurations=[config]) config_file.write_text(wrapped_config.model_dump_json()) dest_dir = tmp_path / "mounts" dest_dir.mkdir() @@ -229,7 +229,7 @@ def test_open_shows_error_on_failure(runner, encrypted_device, tmp_path: Path) - update={"DevicePassCmd": "echo wrong_password"} ) config_file = tmp_path / "config.json" - wrapped_config = cp.Configuration(deviceConfigurations=[config]) + wrapped_config = cp.Configuration(DeviceConfigurations=[config]) config_file.write_text(wrapped_config.model_dump_json()) dest_dir = tmp_path / "mounts" dest_dir.mkdir() @@ -310,7 +310,7 @@ def test_format_device_creates_expected_file_system( assert format_result.exit_code == 0 serialised_config = format_result.stdout parsed = cp.parse_configuration(serialised_config) - config_lst = parsed.deviceConfigurations + config_lst = parsed.DeviceConfigurations assert len(config_lst) == 1 config = config_lst[0] with sdm.decrypted_device(big_file, config.DevicePassCmd) as decrypted: @@ -323,7 +323,7 @@ def test_format_device(runner, backend: str, big_file: Path) -> None: format_result = runner.invoke(app, ["format-device", backend, str(big_file)]) serialised_config = format_result.stdout parsed = cp.parse_configuration(serialised_config) - config_lst = parsed.deviceConfigurations + config_lst = parsed.DeviceConfigurations assert len(config_lst) == 1 device_uuid = config_lst[0].UUID device_name = config_lst[0].Name @@ -348,7 +348,7 @@ def test_format_device_chowns_filesystem_to_user( format_result = runner.invoke(app, ["format-device", backend, str(big_file)]) serialised_config = format_result.stdout parsed = cp.parse_configuration(serialised_config) - config_lst = parsed.deviceConfigurations + config_lst = parsed.DeviceConfigurations assert len(config_lst) == 1 config = config_lst[0] @@ -382,7 +382,7 @@ def test_do_backup_refuses_backup_when_device_is_already_open( config_file = tmp_path / "config.json" - wrapped_config = cp.Configuration(deviceConfigurations=[config]) + wrapped_config = cp.Configuration(DeviceConfigurations=[config]) config_file.write_text(wrapped_config.model_dump_json()) runner.invoke(app, ["open", "--config", str(config_file)]) result = runner.invoke(app, [subprogram, "--config", str(config_file)]) @@ -417,7 +417,7 @@ def test_unmount_error_does_not_cause_content_deletion( config = complement_configuration(encrypted_device, tmp_path) prepare_tmp_path(config, tmp_path) config_file = tmp_path / "config.json" - wrapped = cp.Configuration(deviceConfigurations=[config]) + wrapped = cp.Configuration(DeviceConfigurations=[config]) config_file.write_text(wrapped.model_dump_json()) result = runner.invoke(app, ["backup", "--config", str(config_file)]) diff --git a/tests/cli/test_sudo_pass_cmd.py b/tests/cli/test_sudo_pass_cmd.py index 002496e..1ae9d23 100644 --- a/tests/cli/test_sudo_pass_cmd.py +++ b/tests/cli/test_sudo_pass_cmd.py @@ -37,7 +37,7 @@ def test_sudo_pass_cmd_is_used_in_open( ) -> None: sudo_pass_cmd = "echo test_password" wrapped_config = cp.Configuration( - deviceConfigurations=[encrypted_device], SudoPassCmd=sudo_pass_cmd + DeviceConfigurations=[encrypted_device], SudoPassCmd=sudo_pass_cmd ) config_file = tmp_path / "config.json" config_file.write_text(wrapped_config.model_dump_json()) @@ -66,7 +66,7 @@ def test_sudo_pass_cmd_is_used_in_backup( ) -> None: sudo_pass_cmd = "echo test_password" wrapped_config = cp.Configuration( - deviceConfigurations=[encrypted_device], SudoPassCmd=sudo_pass_cmd + DeviceConfigurations=[encrypted_device], SudoPassCmd=sudo_pass_cmd ) config_file = tmp_path / "config.json" config_file.write_text(wrapped_config.model_dump_json()) @@ -97,7 +97,7 @@ def test_sudo_pass_cmd_is_used_in_close( ) -> None: sudo_pass_cmd = "echo test_password" wrapped_config = cp.Configuration( - deviceConfigurations=[encrypted_device], SudoPassCmd=sudo_pass_cmd + DeviceConfigurations=[encrypted_device], SudoPassCmd=sudo_pass_cmd ) config_file = tmp_path / "config.json" config_file.write_text(wrapped_config.model_dump_json()) @@ -147,11 +147,11 @@ def test_open_requires_correct_sudo_pass_cmd( config = complement_configuration(encrypted_device, tmp_path) prepare_tmp_path(config, tmp_path) correct_config = cp.Configuration( - deviceConfigurations=[config], SudoPassCmd=_SUDO_PASS_CMD + DeviceConfigurations=[config], SudoPassCmd=_SUDO_PASS_CMD ) correct_config_file = tmp_path / "correct_config.json" correct_config_file.write_text(correct_config.model_dump_json()) - wrong_config = cp.Configuration(deviceConfigurations=[config], SudoPassCmd="false") + wrong_config = cp.Configuration(DeviceConfigurations=[config], SudoPassCmd="false") wrong_config_file = tmp_path / "wrong_config.json" wrong_config_file.write_text(wrong_config.model_dump_json()) diff --git a/tests/config_parser/test_parse_configuration.py b/tests/config_parser/test_parse_configuration.py index 6f9149f..343cab0 100644 --- a/tests/config_parser/test_parse_configuration.py +++ b/tests/config_parser/test_parse_configuration.py @@ -29,7 +29,7 @@ def test_example_files_can_be_parsed(example_file: Path) -> None: content = example_file.read_text() result = cp.parse_configuration(content) expected_names = ["BtrFS Backup Example", "Restic Backup Example"] - assert [cfg.Name for cfg in result.deviceConfigurations] == expected_names + assert [cfg.Name for cfg in result.DeviceConfigurations] == expected_names @given( @@ -49,7 +49,7 @@ def test_parse_configuration_rejects_duplicate_names( cp.parse_configuration( json.dumps( { - "deviceConfigurations": [ + "DeviceConfigurations": [ first_config.model_dump(mode="json"), second_config.model_dump(mode="json"), ] @@ -60,7 +60,7 @@ def test_parse_configuration_rejects_duplicate_names( def test_parse_configuration_rejects_empty_list() -> None: with pytest.raises(SystemExit) as sysexit: - cp.parse_configuration(json.dumps({"deviceConfigurations": []})) + cp.parse_configuration(json.dumps({"DeviceConfigurations": []})) assert sysexit.value.code not in SUCCESS_CODES @@ -71,12 +71,12 @@ def test_parse_configuration_rejects_empty_list() -> None: ) def test_parse_configuration_warns_on_non_lists(non_list) -> None: with pytest.raises(ValidationError): - cp.parse_configuration(json.dumps({"deviceConfigurations": non_list})) + cp.parse_configuration(json.dumps({"DeviceConfigurations": non_list})) def test_parse_configuration_warns_on_non_dict_item() -> None: with pytest.raises(ValidationError): - cp.parse_configuration(json.dumps({"deviceConfigurations": [{}, 1337]})) + cp.parse_configuration(json.dumps({"DeviceConfigurations": [{}, 1337]})) @given( @@ -92,7 +92,7 @@ def test_parse_configuration_parses_btrfs_config( with TemporaryDirectory() as source: base_config.update({"Folders": {source: folders_dest}, "FilesDest": files_dest}) btrfs_cfg = cp.BtrFSRsyncConfig.model_validate(base_config) - cfg = cp.Configuration(deviceConfigurations=[btrfs_cfg]) + cfg = cp.Configuration(DeviceConfigurations=[btrfs_cfg]) result = cp.parse_configuration(cfg.model_dump_json()) assert result == cfg @@ -104,7 +104,7 @@ def test_load_configuration_parses_restic_config(base_config: dict[str, t.Any]) with TemporaryDirectory() as source: base_config["FilesAndFolders"] = [source] restic_cfg = cp.ResticConfig.model_validate(base_config) - cfg = cp.Configuration(deviceConfigurations=[restic_cfg]) + cfg = cp.Configuration(DeviceConfigurations=[restic_cfg]) result = cp.parse_configuration(cfg.model_dump_json()) assert result == cfg @@ -122,7 +122,7 @@ def test_parse_configuration_with_sudo_pass_cmd( sudo_pass_cmd: str, ) -> None: cfg = cp.Configuration( - deviceConfigurations=device_configurations, + DeviceConfigurations=device_configurations, SudoPassCmd=sudo_pass_cmd, ) raw = cfg.model_dump_json()