From b1f568db2e635511aeaa50539de8bcb142e2bda2 Mon Sep 17 00:00:00 2001 From: Peder Bergebakken Sundt Date: Mon, 10 Mar 2025 11:20:47 +0100 Subject: [PATCH] Skip rsync if `excludes` contains '*' We do not skip if it should `mirror`, or if there are any `includes` or `extra_args` as they come before `excludes`. --- src/remote/util.py | 9 ++++++++ test/test_util.py | 51 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/src/remote/util.py b/src/remote/util.py index f06afda..104f3c7 100644 --- a/src/remote/util.py +++ b/src/remote/util.py @@ -217,6 +217,15 @@ def rsync( if extra_args: args.extend(extra_args) + # https://man.archlinux.org/man/extra/rsync/rsync.1.en#FILTER_RULES + if ( + not (includes or extra_args or mirror) + and excludes is not None + and any(exclude in ("*", "**", "***") for exclude in excludes) + ): + logger.info("Skipping sync due to '*' in excludes and no includes") + return + cleanup: List[Path] = [] # It is important to add include patterns before exclude patters because rsync might ignore includes if you do otherwise. _gen_rsync_patterns_file(includes, "--include-from", args, cleanup) diff --git a/test/test_util.py b/test/test_util.py index 62394e4..c294ea7 100644 --- a/test/test_util.py +++ b/test/test_util.py @@ -1,3 +1,4 @@ +import functools import sys from unittest.mock import MagicMock, patch @@ -75,6 +76,56 @@ def test_rsync_copies_files_with_mirror(tmp_path, rsync_ssh): assert not (dst / "fourth.txt").exists() +@pytest.mark.parametrize( + "should_be_skipped, includes, excludes", + [ + (False, [], []), + (False, ["/.remoteenv"], ["*", "foobar"]), + (False, ["/.remoteenv"], ["*"]), + (False, ["/.remoteenv"], ["**"]), + (False, ["/.remoteenv"], ["***"]), + (True, [], ["*", "foobar"]), + (True, [], ["*"]), + (True, [], ["**", "foobar"]), + (True, [], ["**"]), + (True, [], ["***", "foobar"]), + (True, [], ["***"]), + ], +) +@patch("remote.util.subprocess.run") +def test_rsync_skip_on_globstar_exclude(mock_run, rsync_ssh, should_be_skipped, includes, excludes): + mock_run.return_value = MagicMock(returncode=0) + + rsync_with = functools.partial( + rsync, + "src/", + "dst", + rsync_ssh, + info=True, + verbose=True, + mirror=False, + dry_run=True, + includes=includes, + excludes=excludes, + ) + + rsync_with(mirror=False, extra_args=None) + if should_be_skipped: + mock_run.assert_not_called() + else: + mock_run.assert_called_once() + + mock_run.reset_mock() + + rsync_with(mirror=False, extra_args=["--some-extra"]) + mock_run.assert_called_once() + + mock_run.reset_mock() + + rsync_with(mirror=True, extra_args=None) + mock_run.assert_called_once() + + @patch("remote.util.subprocess.run") def test_rsync_respects_all_options(mock_run, rsync_ssh): mock_run.return_value = MagicMock(returncode=0)