From bfd316694d576157b078a2ef37a8c3b4c4015a18 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Wed, 9 Oct 2024 19:27:37 +0200 Subject: [PATCH 1/3] implement special tags, see #953 special tags start with @ and have clobber protection, so users can't accidentally remove them using borg tag --set. it is possible though to still use --set, but one must also give all special tags that the archive(s) already have. there is only a known set of allowed special tags: @PROT - protects archives against archive pruning or archive deletion setting unknown tags beginning with @ is disallowed. --- src/borg/archiver/tag_cmd.py | 29 +++++++++++++++++-- src/borg/constants.py | 4 +++ src/borg/testsuite/archiver/tag_cmd_test.py | 31 +++++++++++++++++++++ 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/src/borg/archiver/tag_cmd.py b/src/borg/archiver/tag_cmd.py index de9b44499..9827c6e9e 100644 --- a/src/borg/archiver/tag_cmd.py +++ b/src/borg/archiver/tag_cmd.py @@ -3,7 +3,7 @@ import argparse from ._common import with_repository, define_archive_filters_group from ..archive import Archive from ..constants import * # NOQA -from ..helpers import bin_to_hex, archivename_validator, tag_validator +from ..helpers import bin_to_hex, archivename_validator, tag_validator, Error from ..manifest import Manifest from ..logger import create_logger @@ -25,10 +25,26 @@ class TagMixIn: else: archive_infos = manifest.archives.list_considering(args) + def check_special(tags): + if tags: + special = {tag for tag in tags_set(tags) if tag.startswith("@")} + if not special.issubset(SPECIAL_TAGS): + raise Error("unknown special tags given.") + + check_special(args.set_tags) + check_special(args.add_tags) + check_special(args.remove_tags) + for archive_info in archive_infos: archive = Archive(manifest, archive_info.id, cache=cache) if args.set_tags: - archive.tags = tags_set(args.set_tags) + # avoid that --set (accidentally) erases existing special tags, + # but allow --set if the existing special tags are also given. + new_tags = tags_set(args.set_tags) + existing_special = {tag for tag in archive.tags if tag.startswith("@")} + clobber = not existing_special.issubset(new_tags) + if not clobber: + archive.tags = new_tags if args.add_tags: archive.tags |= tags_set(args.add_tags) if args.remove_tags: @@ -53,6 +69,15 @@ class TagMixIn: You can set the tags to a specific set of tags or you can add or remove tags from the current set of tags. + + User defined tags must not start with `@` because such tags are considered + special and users are only allowed to use known special tags: + + ``@PROT``: protects archives against archive deletion or pruning. + + Pre-existing special tags can not be removed via ``--set``. You can still use + ``--set``, but you must give pre-existing special tags also (so they won't be + removed). """ ) subparser = subparsers.add_parser( diff --git a/src/borg/constants.py b/src/borg/constants.py index 36017d44f..911a8f1be 100644 --- a/src/borg/constants.py +++ b/src/borg/constants.py @@ -124,6 +124,10 @@ TIME_DIFFERS2_NS = 3000000000 # tar related SCHILY_XATTR = "SCHILY.xattr." # xattr key prefix in tar PAX headers +# special tags +# @PROT protects archives against accidential deletion or modification by delete, prune or recreate. +SPECIAL_TAGS = frozenset(["@PROT"]) + # return codes returned by borg command EXIT_SUCCESS = 0 # everything done, no problems EXIT_WARNING = 1 # reached normal end of operation, but there were issues (generic warning) diff --git a/src/borg/testsuite/archiver/tag_cmd_test.py b/src/borg/testsuite/archiver/tag_cmd_test.py index a5e556f4c..06be79730 100644 --- a/src/borg/testsuite/archiver/tag_cmd_test.py +++ b/src/borg/testsuite/archiver/tag_cmd_test.py @@ -1,5 +1,8 @@ +import pytest + from ...constants import * # NOQA from . import cmd, generate_archiver_tests, RK_ENCRYPTION +from ...helpers import Error pytest_generate_tests = lambda metafunc: generate_archiver_tests(metafunc, kinds="local") # NOQA @@ -30,3 +33,31 @@ def test_tag_add_remove(archivers, request): assert "tags: bb." in output output = cmd(archiver, "tag", "-a", "archive", "--remove", "bb") assert "tags: ." in output + + +def test_tag_set_noclobber_special(archivers, request): + archiver = request.getfixturevalue(archivers) + cmd(archiver, "repo-create", RK_ENCRYPTION) + cmd(archiver, "create", "archive", archiver.input_path) + output = cmd(archiver, "tag", "-a", "archive", "--set", "@PROT") + assert "tags: @PROT." in output + # archive now has a special tag. + # it must not be possible to accidentally erase such special tags by using --set: + output = cmd(archiver, "tag", "-a", "archive", "--set", "clobber") + assert "tags: @PROT." in output + # it is possible though to use --set if the existing special tags are also given: + output = cmd(archiver, "tag", "-a", "archive", "--set", "noclobber", "--set", "@PROT") + assert "tags: @PROT,noclobber." in output + + +def test_tag_only_known_special(archivers, request): + archiver = request.getfixturevalue(archivers) + cmd(archiver, "repo-create", RK_ENCRYPTION) + cmd(archiver, "create", "archive", archiver.input_path) + # user can't set / add / remove unknown special tags + with pytest.raises(Error): + cmd(archiver, "tag", "-a", "archive", "--set", "@UNKNOWN") + with pytest.raises(Error): + cmd(archiver, "tag", "-a", "archive", "--add", "@UNKNOWN") + with pytest.raises(Error): + cmd(archiver, "tag", "-a", "archive", "--remove", "@UNKNOWN") From 9d187d61ceef408117df31dc66617ce9ffdfbf59 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Wed, 9 Oct 2024 23:25:57 +0200 Subject: [PATCH 2/3] delete/prune: do not delete/prune protected archives --- src/borg/archiver/delete_cmd.py | 1 + src/borg/archiver/prune_cmd.py | 1 + src/borg/testsuite/archiver/delete_cmd_test.py | 16 ++++++++++++++++ src/borg/testsuite/archiver/prune_cmd_test.py | 16 ++++++++++++++++ 4 files changed, 34 insertions(+) diff --git a/src/borg/archiver/delete_cmd.py b/src/borg/archiver/delete_cmd.py index 5ad81f95b..5ff05d33d 100644 --- a/src/borg/archiver/delete_cmd.py +++ b/src/borg/archiver/delete_cmd.py @@ -22,6 +22,7 @@ class DeleteMixIn: archive_infos = [manifest.archives.get_one([args.name])] else: archive_infos = manifest.archives.list_considering(args) + archive_infos = [ai for ai in archive_infos if "@PROT" not in ai.tags] count = len(archive_infos) if count == 0: return diff --git a/src/borg/archiver/prune_cmd.py b/src/borg/archiver/prune_cmd.py index 42a375c21..fd0f0fcfd 100644 --- a/src/borg/archiver/prune_cmd.py +++ b/src/borg/archiver/prune_cmd.py @@ -151,6 +151,7 @@ class PruneMixIn: match = args.name if args.name else args.match_archives archives = manifest.archives.list(match=match, sort_by=["ts"], reverse=True) + archives = [ai for ai in archives if "@PROT" not in ai.tags] keep = [] # collect the rule responsible for the keeping of each archive in this dict diff --git a/src/borg/testsuite/archiver/delete_cmd_test.py b/src/borg/testsuite/archiver/delete_cmd_test.py index e07ef2736..a163f3d71 100644 --- a/src/borg/testsuite/archiver/delete_cmd_test.py +++ b/src/borg/testsuite/archiver/delete_cmd_test.py @@ -32,3 +32,19 @@ def test_delete_multiple(archivers, request): cmd(archiver, "delete", "-a", "test1") cmd(archiver, "delete", "-a", "test2") assert not cmd(archiver, "repo-list") + + +def test_delete_ignore_protected(archivers, request): + archiver = request.getfixturevalue(archivers) + create_regular_file(archiver.input_path, "file1", size=1024 * 80) + cmd(archiver, "repo-create", RK_ENCRYPTION) + cmd(archiver, "create", "test1", "input") + cmd(archiver, "tag", "--add=@PROT", "test1") + cmd(archiver, "create", "test2", "input") + cmd(archiver, "delete", "-a", "test1") + cmd(archiver, "delete", "-a", "test2") + cmd(archiver, "delete", "-a", "sh:test*") + output = cmd(archiver, "repo-list") + assert "@PROT" in output + assert "test1" in output + assert "test2" not in output diff --git a/src/borg/testsuite/archiver/prune_cmd_test.py b/src/borg/testsuite/archiver/prune_cmd_test.py index 2c44064bf..207f2bdb7 100644 --- a/src/borg/testsuite/archiver/prune_cmd_test.py +++ b/src/borg/testsuite/archiver/prune_cmd_test.py @@ -241,3 +241,19 @@ def test_prune_repository_glob(archivers, request): assert "2015-08-12-20:00-foo" in output assert "2015-08-12-10:00-bar" in output assert "2015-08-12-20:00-bar" in output + + +def test_prune_ignore_protected(archivers, request): + archiver = request.getfixturevalue(archivers) + cmd(archiver, "repo-create", RK_ENCRYPTION) + cmd(archiver, "create", "archive1", archiver.input_path) + cmd(archiver, "tag", "--set=@PROT", "archive1") # do not delete archive1! + cmd(archiver, "create", "archive2", archiver.input_path) + cmd(archiver, "create", "archive3", archiver.input_path) + output = cmd(archiver, "prune", "--list", "--keep-last=1", "--match-archives=sh:archive*") + assert "archive1" not in output # @PROT archives are completely ignored. + assert re.search(r"Keeping archive \(rule: secondly #1\):\s+archive3", output) + assert re.search(r"Pruning archive \(.*?\):\s+archive2", output) + output = cmd(archiver, "repo-list") + assert "archive1" in output # @PROT protected archive1 from deletion + assert "archive3" in output # last one From 3fb5d3f227c0eede17181fad6bdb3418a1e97de5 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Thu, 10 Oct 2024 00:33:15 +0200 Subject: [PATCH 3/3] recreate: do not recreate protected archives --- src/borg/archiver/recreate_cmd.py | 5 +++-- src/borg/testsuite/archiver/recreate_cmd_test.py | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/borg/archiver/recreate_cmd.py b/src/borg/archiver/recreate_cmd.py index 80ef33bff..26efe46fc 100644 --- a/src/borg/archiver/recreate_cmd.py +++ b/src/borg/archiver/recreate_cmd.py @@ -37,8 +37,9 @@ class RecreateMixIn: dry_run=args.dry_run, timestamp=args.timestamp, ) - - for archive_info in manifest.archives.list_considering(args): + archive_infos = manifest.archives.list_considering(args) + archive_infos = [ai for ai in archive_infos if "@PROT" not in ai.tags] + for archive_info in archive_infos: if recreater.is_temporary_archive(archive_info.name): continue name, hex_id = archive_info.name, bin_to_hex(archive_info.id) diff --git a/src/borg/testsuite/archiver/recreate_cmd_test.py b/src/borg/testsuite/archiver/recreate_cmd_test.py index d55209df4..7c2dc879e 100644 --- a/src/borg/testsuite/archiver/recreate_cmd_test.py +++ b/src/borg/testsuite/archiver/recreate_cmd_test.py @@ -274,3 +274,18 @@ def test_comment(archivers, request): assert "Comment: modified comment" in cmd(archiver, "info", "-a", "test2") assert "Comment: " + os.linesep in cmd(archiver, "info", "-a", "test3") assert "Comment: preserved comment" in cmd(archiver, "info", "-a", "test4") + + +def test_recreate_ignore_protected(archivers, request): + archiver = request.getfixturevalue(archivers) + create_test_files(archiver.input_path) + create_regular_file(archiver.input_path, "file1", size=1024) + create_regular_file(archiver.input_path, "file2", size=1024) + cmd(archiver, "repo-create", RK_ENCRYPTION) + cmd(archiver, "create", "archive", "input") + cmd(archiver, "tag", "--add=@PROT", "archive") + cmd(archiver, "recreate", "archive", "-e", "input") # this would normally remove all from archive + listing = cmd(archiver, "list", "archive", "--short") + # archive was protected, so recreate ignored it: + assert "file1" in listing + assert "file2" in listing