From 552646cc9b3c80adfc8e110fd50f43847362bcee Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Thu, 15 May 2025 15:52:22 +0200 Subject: [PATCH 1/2] BORG_REPO_PERMISSIONS=all|no-delete|read-only The posixfs borgstore backend implements permissions to make testing with differently permissive stores easier. The env var selects from pre-defined permission configurations within borg and gives the chosen permissions config to borgstore. --- src/borg/repository.py | 24 ++- .../archiver/restricted_permissions_test.py | 138 ++++++++++++++++++ 2 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 src/borg/testsuite/archiver/restricted_permissions_test.py diff --git a/src/borg/repository.py b/src/borg/repository.py index 4218e38a3..ed51b5a5f 100644 --- a/src/borg/repository.py +++ b/src/borg/repository.py @@ -115,8 +115,30 @@ class Repository: "keys/": [0], "locks/": [0], } + # Get permissions from environment variable + permissions = os.environ.get("BORG_REPO_PERMISSIONS", "all") + + if permissions == "all": + permissions = None # permissions system will not be used + elif permissions == "no-delete": # mostly no delete, no overwrite + permissions = { + "": "lr", + "archives": "lrw", + "cache": "lrwWD", # WD for chunks.X + "config": "lrWD", # W for manifest, D for last-key-checked + "data": "lrw", + "keys": "lr", + "locks": "lrwD", # borg needs to create/delete a shared lock here + } + elif permissions == "read-only": # mostly r/o + permissions = {"": "lr", "locks": "lrwD"} + else: + raise Error( + f"Invalid BORG_REPO_PERMISSIONS value: {permissions}, should be one of: all, no-delete, read-only" + ) + try: - self.store = Store(url, levels=levels_config) + self.store = Store(url, levels=levels_config, permissions=permissions) except StoreBackendError as e: raise Error(str(e)) self.store_opened = False diff --git a/src/borg/testsuite/archiver/restricted_permissions_test.py b/src/borg/testsuite/archiver/restricted_permissions_test.py new file mode 100644 index 000000000..bea3ce447 --- /dev/null +++ b/src/borg/testsuite/archiver/restricted_permissions_test.py @@ -0,0 +1,138 @@ +import os +import pytest + +from borgstore.backends.errors import PermissionDenied + +from ...constants import * # NOQA +from .. import changedir +from . import cmd, create_test_files, RK_ENCRYPTION, generate_archiver_tests + +pytest_generate_tests = lambda metafunc: generate_archiver_tests(metafunc, kinds="local") # NOQA + + +def test_repository_permissions_all(archivers, request, monkeypatch): + """Test repository with 'all' permissions setting""" + archiver = request.getfixturevalue(archivers) + + # Create a repository with unrestricted permissions. + monkeypatch.setenv("BORG_REPO_PERMISSIONS", "all") + cmd(archiver, "repo-create", RK_ENCRYPTION) + + create_test_files(archiver.input_path) + cmd(archiver, "create", "archive1", "input") + + # Verify the archive was created. + assert "archive1" in cmd(archiver, "repo-list") + + # Delete the archive to verify unrestricted permissions. + cmd(archiver, "delete", "archive1") + + # Verify the archive was deleted. + assert "archive1" not in cmd(archiver, "repo-list") + + # Delete the repository to verify unrestricted permissions. + cmd(archiver, "repo-delete") + + +def test_repository_permissions_no_delete(archivers, request, monkeypatch): + """Test repository with 'no-delete' permissions setting""" + archiver = request.getfixturevalue(archivers) + create_test_files(archiver.input_path) + + # Create a repository first (need unrestricted permissions for that). + monkeypatch.setenv("BORG_REPO_PERMISSIONS", "all") + cmd(archiver, "repo-create", RK_ENCRYPTION) + cmd(archiver, "create", "archive1", "input") + cmd(archiver, "delete", "archive1") # this is so that compact has some chunk to remove + + # Switch to no-delete permissions. + monkeypatch.setenv("BORG_REPO_PERMISSIONS", "no-delete") + + # Creating new archives should work. + cmd(archiver, "create", "archive2", "input") + + # Verify the archive was created. + assert "archive2" in cmd(archiver, "repo-list") + + # Try to delete the archive, which should fail. + with pytest.raises(PermissionDenied): + cmd(archiver, "delete", "archive2") + + # Verify the archive still exists. + assert "archive2" in cmd(archiver, "repo-list") + + # Try to rename an archive, which should fail. + with pytest.raises(PermissionDenied): + cmd(archiver, "rename", "archive2", "archive3") + + # Verify the archive still exists. + assert "archive2" in cmd(archiver, "repo-list") + + # Try to delete the repo, which should fail. + with pytest.raises(PermissionDenied): + cmd(archiver, "repo-delete") + + # Verify the archive still exists. + assert "archive2" in cmd(archiver, "repo-list") + + # Try to compact the repo, which should fail. + with pytest.raises(PermissionDenied): + cmd(archiver, "compact") + + # Check without --repair should work. + cmd(archiver, "check") + + # Try to check --repair, which should fail. + with pytest.raises(PermissionDenied): + cmd(archiver, "check", "--repair") + + # Try to repo-compress (and change compression from lz4 to zstd), which should fail. + # It fails because it needs to overwrite existing chunks, which is also disallowed by no-delete. + with pytest.raises(PermissionDenied): + cmd(archiver, "repo-compress", "-C", "zstd") + + +def test_repository_permissions_read_only(archivers, request, monkeypatch): + """Test repository with 'read-only' permissions setting""" + archiver = request.getfixturevalue(archivers) + + # Create a repository first (need unrestricted permissions for that). + monkeypatch.setenv("BORG_REPO_PERMISSIONS", "all") + cmd(archiver, "repo-create", RK_ENCRYPTION) + + # Create an archive to test with. + create_test_files(archiver.input_path) + cmd(archiver, "create", "archive2", "input") + + # Switch to read-only permissions. + monkeypatch.setenv("BORG_REPO_PERMISSIONS", "read-only") + + # Verify we can list archives. + assert "archive2" in cmd(archiver, "repo-list") + + # Verify we can list files in an archive. + assert "input/" in cmd(archiver, "list", "archive2") + + # Extract the archive. + with changedir("output"): + cmd(archiver, "extract", "archive2") + + # Verify extraction worked. + extracted_files = os.listdir("output") + assert len(extracted_files) > 0 + + # Try to create a new archive, which should fail. + with pytest.raises(PermissionDenied): + cmd(archiver, "create", "archive3", "input") + + # Try to delete an archive, which should fail. + with pytest.raises(PermissionDenied): + cmd(archiver, "delete", "archive2") + + # Try to delete the repo, which should fail. + with pytest.raises(PermissionDenied): + cmd(archiver, "repo-delete") + + # Try to compact the repo, which should fail. + with pytest.raises(PermissionDenied): + cmd(archiver, "compact") From 7cef820b7ea3fdc77dbf558c7489963d744c8dc7 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Thu, 15 May 2025 23:57:45 +0200 Subject: [PATCH 2/2] pull borgstore from master branch --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b89352061..cfb321603 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ license = "BSD-3-Clause" license-files = ["LICENSE", "AUTHORS"] dependencies = [ "borghash ~= 0.1.0", - "borgstore ~= 0.2.0", + "borgstore @ git+https://github.com/borgbackup/borgstore.git@master", # temporary until there is a release "msgpack >=1.0.3, <=1.1.0", "packaging", "platformdirs >=3.0.0, <5.0.0; sys_platform == 'darwin'", # for macOS: breaking changes in 3.0.0,