diff --git a/docs/faq.rst b/docs/faq.rst index e97028f0e9..d7daf12b23 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -1362,6 +1362,10 @@ interacts with the repository (e.g., via ``borg repo-list``), depending on the v of ``BORG_RELOCATED_REPO_ACCESS_IS_OK``, borg may warn you that the repository has been moved. You will be given a prompt to confirm you are OK with this. +Accessing the same local repository directory via a different path (e.g., via a +symlink, a bind mount or a different letter case on a case-insensitive filesystem) +is not considered a relocation, as long as the previous path still exists. + If ``BORG_RELOCATED_REPO_ACCESS_IS_OK`` is unset, borg will interactively ask for each repository whether it's OK. diff --git a/src/borg/security.py b/src/borg/security.py index fb51071f78..a06af1acf2 100644 --- a/src/borg/security.py +++ b/src/borg/security.py @@ -1,3 +1,4 @@ +import os import shutil from pathlib import Path @@ -130,7 +131,11 @@ def assert_location_matches(self): # produced in different normalization forms (NFC when typed, NFD when derived from the filesystem), # which would otherwise be mistaken for a repository relocation (see issue #2913). Normalizing is # idempotent and applied symmetrically, so it never creates a spurious mismatch for remote repos. - if previous_location and normalize_local_path(previous_location) != normalize_local_path(repository_location): + if ( + previous_location + and normalize_local_path(previous_location) != normalize_local_path(repository_location) + and not self._is_same_local_directory(previous_location, repository_location) + ): msg = ( "Warning: The repository at location {} was previously located at {}\n".format( repository_location, previous_location @@ -150,6 +155,26 @@ def assert_location_matches(self): with SaveFile(self.location_file) as fd: fd.write(repository_location) + def _is_same_local_directory(self, previous_location, repository_location): + """Return True if both locations are local paths that refer to the same existing directory. + + The same local repository directory can be reached via different paths, e.g. through a symlink, + a bind mount or a different letter case on a case-insensitive filesystem (see issue #8188). + This is not a relocation, so no warning is needed. The stored location is not updated. + """ + if self.repository._location.proto != "file": + return False + if "://" in previous_location or not os.path.isabs(previous_location): + return False + try: + previous_st = os.stat(previous_location) + current_st = os.stat(repository_location) + except OSError: + # the previous path does not exist anymore (e.g. the repository was moved or renamed). + return False + # some filesystems report st_ino == 0 for everything, which would make any two paths look the same. + return previous_st.st_ino != 0 and os.path.samestat(previous_st, current_st) + def assert_key_type(self, key): # Make sure an encrypted repository has not been swapped for an unencrypted repository if self.known() and not self.key_matches(key): diff --git a/src/borg/testsuite/archiver/checks_test.py b/src/borg/testsuite/archiver/checks_test.py index bf76ed05b3..2bb6f246e7 100644 --- a/src/borg/testsuite/archiver/checks_test.py +++ b/src/borg/testsuite/archiver/checks_test.py @@ -9,7 +9,7 @@ from ...helpers import Location, get_security_dir, bin_to_hex from ...helpers import EXIT_ERROR from ...repository import Repository -from .. import changedir +from .. import changedir, are_symlinks_supported from . import cmd, _extract_repository_id, create_test_files from . import create_regular_file, assert_creates_file, generate_archiver_tests, RK_ENCRYPTION from . import set_empty_passphrase @@ -172,6 +172,33 @@ def test_repository_move(archivers, request, monkeypatch): assert os.path.exists(os.path.join(security_dir, file)) +@pytest.mark.skipif(not are_symlinks_supported(), reason="symlinks not supported") +def test_repository_access_via_symlink(archiver, monkeypatch): + # accessing the same repository directory via a different path (here: a symlink) is not a relocation. + cmd(archiver, "repo-create", RK_ENCRYPTION) + security_dir = get_security_directory(archiver.repository_path) + repo_location = archiver.repository_location + symlink_location = archiver.repository_path + "_symlink" + os.symlink(archiver.repository_path, symlink_location) + monkeypatch.setenv("BORG_RELOCATED_REPO_ACCESS_IS_OK", "no") + for location in (repo_location, symlink_location, repo_location): + archiver.repository_location = location + cmd(archiver, "repo-info") + # the stored location is not updated. + with open(os.path.join(security_dir, "location")) as fd: + assert fd.read() == Location(repo_location).canonical_path() + + +def test_repository_previously_remote(archiver): + # a repository previously accessed via a URL, now accessed via a local path, is a relocation. + cmd(archiver, "repo-create", RK_ENCRYPTION) + security_dir = get_security_directory(archiver.repository_path) + with open(os.path.join(security_dir, "location"), "w") as fd: + fd.write(Location("ssh://__testsuite__/" + archiver.repository_path).canonical_path()) + with pytest.raises(Cache.RepositoryAccessAborted): + cmd(archiver, "repo-info") + + def test_unknown_unencrypted(archivers, request, monkeypatch): archiver = request.getfixturevalue(archivers) cmd(archiver, "repo-create", "--encryption=authenticated-sha256")