From a879f5c7237736f7f69924e93dc6ce4c530eaa1a Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Sun, 11 Oct 2026 18:46:42 +0200 Subject: [PATCH] security: same local repo dir via another path is not a relocation, fixes #8188 The "repository was previously located at" check compared path strings only, so reaching the same local repository directory via a symlink, a bind mount or a different letter case on a case-insensitive filesystem looked like a relocation. Answering yes rewrote the stored location, so alternating between the paths kept triggering the prompt (breaking e.g. unattended prune). If the strings differ, but both are local paths referring to the same existing directory (same st_dev/st_ino, st_ino != 0), do not warn. The stored location and all displayed paths stay as they were. Remote locations are not affected, and a real move/rename (old path gone) still triggers the warning. Co-Authored-By: Claude Opus 5.5 --- docs/faq.rst | 4 +++ src/borg/security.py | 27 +++++++++++++++++++- src/borg/testsuite/archiver/checks_test.py | 29 +++++++++++++++++++++- 3 files changed, 58 insertions(+), 2 deletions(-) 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")