Repository: borgbackup/borg

Issue:
add a "protected" flag for archives
e.g. as a field in the entries of the manifest like the timestamp.

The use case would be an archive that represents a publication- or a "certification approved"-state. The delete function only needs to check the flag and that return without deleting it. This affects the pruning as well of course.

Maybe it could be further implemented that way, that all the chunks of all the protected archives are not written by borg, so that a bug in borg could not harm the protected backup states.
Further this would lead to the possible implementation of a non-locking borg mount feature. [#952](https://github.com/borgbackup/borg/issues/951)


Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/borgbackup/borg/pull/8469?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=borgbackup) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 81.55%. Comparing base [(`7288f4f`)](https://app.codecov.io/gh/borgbackup/borg/commit/7288f4f94d99356f9f68001c638b83474938ad44?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=borgbackup) to head [(`76a388c`)](https://app.codecov.io/gh/borgbackup/borg/commit/76a388c5129b89f1b43e46f88c404be56a349b73?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=borgbackup).
> Report is 4 commits behind head on master.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master    #8469      +/-   ##
==========================================
- Coverage   81.77%   81.55%   -0.22%     
==========================================
  Files          72       72              
  Lines       12995    13025      +30     
  Branches     1919     1927       +8     
==========================================
- Hits        10627    10623       -4     
- Misses       1713     1742      +29     
- Partials      655      660       +5     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/borgbackup/borg/pull/8469?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=borgbackup).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=borgbackup).


Patch:
diff --git a/src/borg/archiver/delete_cmd.py b/src/borg/archiver/delete_cmd.py
index 5ad81f95..5ff05d33 100644
--- a/src/borg/archiver/delete_cmd.py
+++ b/src/borg/archiver/delete_cmd.py
@@ -22,6 +22,7 @@ def do_delete(self, args, repository):
             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 42a375c2..fd0f0fcf 100644
--- a/src/borg/archiver/prune_cmd.py
+++ b/src/borg/archiver/prune_cmd.py
@@ -151,6 +151,7 @@ def do_prune(self, args, repository, manifest):
 
         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/archiver/recreate_cmd.py b/src/borg/archiver/recreate_cmd.py
index 80ef33bf..26efe46f 100644
--- a/src/borg/archiver/recreate_cmd.py
+++ b/src/borg/archiver/recreate_cmd.py
@@ -37,8 +37,9 @@ def do_recreate(self, args, repository, manifest, cache):
             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/archiver/tag_cmd.py b/src/borg/archiver/tag_cmd.py
index de9b4449..9827c6e9 100644
--- a/src/borg/archiver/tag_cmd.py
+++ b/src/borg/archiver/tag_cmd.py
@@ -3,7 +3,7 @@
 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 @@ def tags_set(tags):
         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 @@ def build_parser_tag(self, subparsers, common_parser, mid_common_parser):
 
             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 36017d44..911a8f1b 100644
--- a/src/borg/constants.py
+++ b/src/borg/constants.py
@@ -124,6 +124,10 @@
 # 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)


Test patch:
diff --git a/src/borg/testsuite/archiver/delete_cmd_test.py b/src/borg/testsuite/archiver/delete_cmd_test.py
index e07ef273..a163f3d7 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 2c44064b..207f2bdb 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
diff --git a/src/borg/testsuite/archiver/recreate_cmd_test.py b/src/borg/testsuite/archiver/recreate_cmd_test.py
index d55209df..7c2dc879 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
diff --git a/src/borg/testsuite/archiver/tag_cmd_test.py b/src/borg/testsuite/archiver/tag_cmd_test.py
index a5e556f4..06be7973 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")