Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion src/borg/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from .helpers import Error
from .helpers import Manifest
from .helpers import get_cache_dir, get_security_dir
from .helpers import normalize_local_path
from .helpers import int_to_bigint, bigint_to_int, bin_to_hex, hex_to_bin, parse_stringified_list
from .helpers import format_file_size
from .helpers import safe_ns
Expand Down Expand Up @@ -119,7 +120,12 @@ def assert_location_matches(self, cache_config=None):
logger.debug('security: using previous_location of cache: %r', previous_location)

repository_location = self.repository._location.canonical_path()
if (previous_location and previous_location != repository_location and
# Normalize the unicode form of both paths before comparing them: on macOS the same path can be
# 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) 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) +
Expand Down
16 changes: 14 additions & 2 deletions src/borg/helpers/parseformat.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import shlex
import socket
import stat
import unicodedata
import uuid
from collections import Counter, OrderedDict
from datetime import datetime, timezone
Expand All @@ -23,7 +24,7 @@
from .. import __version__ as borg_version
from .. import __version_tuple__ as borg_version_tuple
from ..constants import * # NOQA
from ..platformflags import is_win32
from ..platformflags import is_win32, is_darwin


def bin_to_hex(binary):
Expand Down Expand Up @@ -322,6 +323,17 @@ def parse_stringified_list(s):
return [item for item in l if item != '']


def normalize_local_path(path):
"""Normalize a local filesystem path's unicode form so paths compare equal regardless of how they were entered.

On macOS the filesystem returns paths in NFD (decomposed) form, while a path typed as a command line
argument is usually in NFC (composed) form. These look identical but are byte-different, which otherwise
makes borg believe a repository was relocated (see issue #2913). We normalize to NFD to match what the
macOS filesystem uses. On other platforms the path is returned unchanged.
"""
return unicodedata.normalize('NFD', path) if is_darwin else path


class Location:
"""Object representing a repository / archive location
"""
Expand Down Expand Up @@ -510,7 +522,7 @@ def host(self):

def canonical_path(self):
if self.proto == 'file':
return self.path
return normalize_local_path(self.path)
else:
if self.path and self.path.startswith('~'):
path = '/' + self.path # /~/x = path x relative to home dir
Expand Down
32 changes: 30 additions & 2 deletions src/borg/testsuite/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import shutil
import signal
import sys
import unicodedata
from argparse import ArgumentTypeError
from datetime import datetime, timezone, timedelta
from io import StringIO, BytesIO
Expand All @@ -12,7 +13,7 @@

from .. import platform, __version__
from ..constants import * # NOQA
from ..helpers import Location
from ..helpers import Location, normalize_local_path
from ..helpers import Buffer
from ..helpers import partial_format, format_file_size, parse_file_size, format_timedelta, format_line, PlaceholderError, replace_placeholders
from ..helpers import make_path_safe, clean_lines
Expand All @@ -34,7 +35,7 @@
from ..helpers import iter_separated
from ..helpers import eval_escapes
from ..helpers import classify_ec, max_ec, add_warning, get_ec, get_reset_ec, init_ec_warnings
from ..platform import is_win32, swidth, is_cygwin
from ..platform import is_win32, swidth, is_cygwin, is_darwin

from . import BaseTestCase, FakeInputs

Expand Down Expand Up @@ -255,6 +256,33 @@ def test_omit_archive(self):
assert loc_without_archive.processed == "ssh://user@host:1234/repos/%s" % hostname


def test_normalize_local_path():
nfc = unicodedata.normalize('NFC', 'tëst') # composed: ë is a single codepoint
nfd = unicodedata.normalize('NFD', 'tëst') # decomposed: e + combining diaeresis
assert nfc != nfd # sanity: the two unicode forms really differ byte-wise
if is_darwin:
# on macOS both forms normalize to the same (NFD) form the filesystem uses
assert normalize_local_path(nfc) == normalize_local_path(nfd) == nfd
else:
# elsewhere it is a no-op
assert normalize_local_path(nfc) == nfc
assert normalize_local_path(nfd) == nfd


def test_canonical_path_unicode_normalization(monkeypatch):
# regression test for #2913: a file repo path entered in NFC form (e.g. typed as an argument) and
# the same path in NFD form (e.g. derived from os.getcwd() on macOS) must yield the same canonical
# path, so borg does not mistake identical-looking paths for a repository relocation.
monkeypatch.delenv('BORG_REPO', raising=False)
nfc = unicodedata.normalize('NFC', '/absolute/tëst')
nfd = unicodedata.normalize('NFD', '/absolute/tëst')
if is_darwin:
assert Location(nfc).canonical_path() == Location(nfd).canonical_path()
else:
# on other platforms the filesystem does not normalize, so the forms stay distinct
assert Location(nfc).canonical_path() != Location(nfd).canonical_path()


class TestLocationWithEnv:
def test_ssh(self, monkeypatch):
monkeypatch.setenv('BORG_REPO', 'ssh://user@host:1234/some/path')
Expand Down
Loading