From 01c7d0a0db806c6ce59ef3914f960bfddaf31328 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Thu, 18 Dec 2025 19:04:42 -0800 Subject: [PATCH] Fix that errors and exits when the borgmatic runtime directory is partially excluded by configured excludes (#1203). --- NEWS | 5 ++++ borgmatic/borg/create.py | 35 ++++++++++++-------------- borgmatic/config/schema.yaml | 4 ++- tests/unit/borg/test_create.py | 45 +++++++++++++++++++++++++++++++--- 4 files changed, 66 insertions(+), 23 deletions(-) diff --git a/NEWS b/NEWS index be6bf2b1..4fd9cdeb 100644 --- a/NEWS +++ b/NEWS @@ -16,6 +16,11 @@ global excludes. * #1201: Document a problematic interaction between borgmatic and systemd-tmpfiles: https://torsion.org/borgmatic/reference/configuration/runtime-directory/#systemd-tmpfiles + * #1203: Fix that errors and exits when the borgmatic runtime directory is partially excluded by + configured excludes. Previously, borgmatic only errored when the runtime directory was completely + excluded. + * #1122: To prevent the user from inadvertently excluding the "bootstrap" action's manifest, always + error and exit when the borgmatic runtime directory overlaps with the configured excludes. * Update the sample systemd timer with a shorter random delay when catching up on a missed run. 2.0.12 diff --git a/borgmatic/borg/create.py b/borgmatic/borg/create.py index a9843df6..4220bb21 100644 --- a/borgmatic/borg/create.py +++ b/borgmatic/borg/create.py @@ -96,32 +96,29 @@ def validate_planned_backup_paths( if path_line and path_line.startswith(('- ', '+ ')) ) - # These are the subset of output paths contained within the borgmatic runtime directory. - paths_inside_runtime_directory = { - path for path in paths if any_parent_directories(path, (borgmatic_runtime_directory,)) - } - - # If the runtime directory isn't present in the source patterns, then we shouldn't expect it to - # be in the paths output from the Borg dry run. - runtime_directory_present_in_patterns = any( + runtime_directory_root_patterns = tuple( pattern for pattern in patterns if any_parent_directories(pattern.path, (borgmatic_runtime_directory,)) if pattern.type == borgmatic.borg.pattern.Pattern_type.ROOT ) - # If no paths to backup are inside the runtime directory, it must've been excluded. - if ( - not paths_inside_runtime_directory - and runtime_directory_present_in_patterns - and not dry_run - and os.path.exists(borgmatic_runtime_directory) - ): - raise ValueError( - f'The runtime directory {os.path.normpath(borgmatic_runtime_directory)} overlaps with the configured excludes or patterns with excludes. Please ensure the runtime directory is not excluded.', - ) + if not dry_run and os.path.exists(borgmatic_runtime_directory): + # If there are any root patterns in the runtime directory that are missing from the paths + # Borg is planning to backup, then they must've gotten excluded, e.g. by user-configured + # excludes. Error accordingly. + for pattern in runtime_directory_root_patterns: + if not any(any_parent_directories(path, (pattern.path,)) for path in paths): + raise ValueError( + f'The runtime directory {os.path.normpath(borgmatic_runtime_directory)} overlaps with the configured excludes or patterns with excludes. Please ensure the runtime directory is not excluded.', + ) - return tuple(path for path in paths if path not in paths_inside_runtime_directory) + # Return the subset of output paths *not* contained within the borgmatic runtime directory. The + # intent is that any downstream checks using these paths should skip runtime paths that + # borgmatic uses for its own bookkeeping, instead focusing on user-configured paths. + return tuple( + path for path in paths if not any_parent_directories(path, (borgmatic_runtime_directory,)) + ) MAX_SPECIAL_FILE_PATHS_LENGTH = 1000 diff --git a/borgmatic/config/schema.yaml b/borgmatic/config/schema.yaml index 84140ae5..19253fd1 100644 --- a/borgmatic/config/schema.yaml +++ b/borgmatic/config/schema.yaml @@ -177,7 +177,9 @@ properties: as "source_directories"; they tell Borg which paths to backup (modulo any excludes). Globs are expanded. (Tildes are not.) See the output of "borg help patterns" for more details. Quote any value - if it contains leading punctuation, so it parses correctly. + if it contains leading punctuation, so it parses correctly. Also use + leading slashes in absolute paths, or data source hooks may be + unable to rewrite patterns as needed. example: - 'R /' - '- /home/*/.cache' diff --git a/tests/unit/borg/test_create.py b/tests/unit/borg/test_create.py index b200a1c8..b8f4c4b8 100644 --- a/tests/unit/borg/test_create.py +++ b/tests/unit/borg/test_create.py @@ -101,7 +101,7 @@ def test_validate_planned_backup_paths_skips_borgmatic_runtime_directory(): ) flexmock(module.os.path).should_receive('exists').and_return(True) flexmock(module).should_receive('any_parent_directories').replace_with( - lambda path, _: path == '/run/borgmatic/bar' + lambda path, candidates: any(path.startswith(parent) for parent in candidates) ) assert module.validate_planned_backup_paths( @@ -134,7 +134,7 @@ def test_validate_planned_backup_paths_with_borgmatic_runtime_directory_missing_ ) flexmock(module.os.path).should_receive('exists').and_return(True) flexmock(module).should_receive('any_parent_directories').replace_with( - lambda path, _: path == '/run/borgmatic/bar' + lambda path, candidates: any(path.startswith(parent) for parent in candidates) ) with pytest.raises(ValueError): @@ -155,6 +155,45 @@ def test_validate_planned_backup_paths_with_borgmatic_runtime_directory_missing_ ) +def test_validate_planned_backup_paths_with_borgmatic_runtime_directory_partially_excluded_from_paths_output_errors(): + flexmock(module.flags).should_receive('omit_flag').replace_with( + lambda arguments, flag: arguments, + ) + flexmock(module.flags).should_receive('omit_flag_and_value').replace_with( + lambda arguments, flag: arguments, + ) + flexmock(module.environment).should_receive('make_environment').and_return(None) + + # /run/borgmatic/bar is present, but /run/borgmatic/quux is missing. + flexmock(module).should_receive('execute_command_and_capture_output').and_return( + '+ /foo\n- /run/borgmatic/bar\n- /baz', + ) + flexmock(module.os.path).should_receive('exists').and_return(True) + flexmock(module).should_receive('any_parent_directories').replace_with( + lambda path, candidates: any(path.startswith(parent) for parent in candidates) + ) + + with pytest.raises(ValueError): + module.validate_planned_backup_paths( + dry_run=False, + create_command=('borg', 'create'), + config={}, + patterns=( + module.borgmatic.borg.pattern.Pattern('/foo'), + module.borgmatic.borg.pattern.Pattern( + '/run/borgmatic/bar', module.borgmatic.borg.pattern.Pattern_type.ROOT + ), + module.borgmatic.borg.pattern.Pattern('/baz'), + module.borgmatic.borg.pattern.Pattern( + '/run/borgmatic/quux', module.borgmatic.borg.pattern.Pattern_type.ROOT + ), + ), + local_path=None, + working_directory=None, + borgmatic_runtime_directory='/run/borgmatic', + ) + + def test_validate_planned_backup_paths_with_borgmatic_runtime_directory_missing_from_patterns_does_not_raise(): flexmock(module.flags).should_receive('omit_flag').replace_with( lambda arguments, flag: arguments, @@ -168,7 +207,7 @@ def test_validate_planned_backup_paths_with_borgmatic_runtime_directory_missing_ ) flexmock(module.os.path).should_receive('exists').and_return(True) flexmock(module).should_receive('any_parent_directories').replace_with( - lambda path, _: path == '/run/borgmatic/bar' + lambda path, candidates: any(path.startswith(parent) for parent in candidates) ) assert module.validate_planned_backup_paths(