From 16ffdc0372684efe35459a8543f8b4d9b03830ca Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Thu, 5 Nov 2026 20:31:26 +0100 Subject: [PATCH] extract: skip a hard link item that refers to itself, refs #10393 If an archive has the same hard link item twice (e.g. a recursion root inside an earlier root, or the same path given twice via --paths-from-stdin) and the item's path is the link target of its hard link group, extract removed the already extracted file and then failed to hard link the path to itself. The file (and its other hard links) was missing afterwards. Such a duplicate item is skipped now, its path was extracted already. This also makes existing archives with such duplicates restore correctly. export-tar and mount skip it likewise: export-tar added a hard link entry pointing to itself (bsdtar refuses it and exits with rc 1), mount counted it as an additional link (st_nlink one too high). Co-Authored-By: Claude Opus 5.5 --- src/borg/archive.py | 6 ++++ src/borg/archiver.py | 4 +++ src/borg/fuse.py | 4 +++ src/borg/testsuite/archiver.py | 58 ++++++++++++++++++++++++++++++++++ 4 files changed, 72 insertions(+) diff --git a/src/borg/archive.py b/src/borg/archive.py index bc40277388..5021e66c0f 100644 --- a/src/borg/archive.py +++ b/src/borg/archive.py @@ -917,6 +917,12 @@ def extract_item(self, item, restore_attrs=True, dry_run=False, stdout=False, sp # overwrite files outside the extraction directory (e.g. /etc/passwd). self.safe_dirs.discard(path) # path is about to be (re)created; never trust a stale entry for it self._check_safe_parent(item.path) + if 'source' in item and hardlinkable(item.mode): + source = os.path.join(dest, *item.source.split(os.sep)[stripped_components:]) + if hardlink_masters.get(item.source, (None, source))[1] == path: + # duplicate item: path was extracted already and is the link target of its hard link group, + # removing it would lose the file (and hard linking it to itself can not work). + return # Attempt to remove existing files, ignore errors on failure. # A trailing "." component means the extraction directory itself (the archive's # "." item, as created by "borg create ARCHIVE ."). posix requires rmdir() to diff --git a/src/borg/archiver.py b/src/borg/archiver.py index 86ba261e98..b2411fc428 100644 --- a/src/borg/archiver.py +++ b/src/borg/archiver.py @@ -1149,6 +1149,10 @@ def item_to_tarinfo(item, original_path): linkname = source else: chunks, linkname = hardlink_masters.get(item.source, (None, source)) + if linkname == item.path: + # duplicate item: path was added already and is the link target of its hard link group, + # a hard link to itself would be useless (and some tar implementations refuse it). + return None, stream if linkname: # Master was already added to the archive, add a hardlink reference to it. tarinfo.type = tarfile.LNKTYPE diff --git a/src/borg/fuse.py b/src/borg/fuse.py index 38347def24..0182597bb5 100644 --- a/src/borg/fuse.py +++ b/src/borg/fuse.py @@ -421,6 +421,10 @@ def make_versioned_name(name, version, add_dir=False): if 'source' in item and hardlinkable(item.mode): source = os.sep.join(item.source.split(os.sep)[stripped_components:]) chunks, link_target = hardlink_masters.get(item.source, (None, source)) + if link_target == path: + # duplicate item: path was added already and is the link target of its hard link group, + # adding it again would only increase the link count. + return if link_target: # Hard link was extracted previously, just link link_target = os.fsencode(link_target) diff --git a/src/borg/testsuite/archiver.py b/src/borg/testsuite/archiver.py index e43a341d4d..53550c72b1 100644 --- a/src/borg/testsuite/archiver.py +++ b/src/borg/testsuite/archiver.py @@ -1208,6 +1208,50 @@ def test_extract_hardlinks_twice(self): assert os.stat('input/a/hardlink').st_nlink == 2 assert os.stat('input/b/hardlink').st_nlink == 2 + @requires_hardlinks + def test_extract_duplicate_hardlink_item(self): + # an archive can have the same hard link item twice (here: the same path given twice via stdin). + # extracting the duplicate must not remove the file that is the link target of its hard link group (#10393). + self.create_regular_file('file1', contents=b'123456') + os.link(os.path.join(self.input_path, 'file1'), os.path.join(self.input_path, 'file2')) + self.cmd('init', '--encryption=none', self.repository_location) + self.cmd('create', '--paths-from-stdin', self.repository_location + '::test', + input=b'input/file1\ninput/file1\ninput/file2\n') + output = self.cmd('list', '--format={path}{NL}', self.repository_location + '::test') + assert output.splitlines() == ['input/file1'] * 2 + ['input/file2'] + with changedir('output'): + self.cmd('extract', self.repository_location + '::test') + assert os.stat('input/file1').st_nlink == 2 + assert os.stat('input/file2').st_nlink == 2 + with open('input/file1', 'rb') as f: + assert f.read() == b'123456' + self.cmd('extract', self.repository_location + '::test', '--strip-components', '1') + assert os.stat('file1').st_nlink == 2 + assert os.stat('file2').st_nlink == 2 + with open('file1', 'rb') as f: + assert f.read() == b'123456' + + @requires_hardlinks + @unittest.skipUnless(llfuse, 'llfuse not installed') + def test_fuse_mount_duplicate_hardlink_item(self): + # an archive can have the same hard link item twice (here: the same path given twice via stdin). + # the duplicate must not increase the link count of its hard link group (#10393). + self.create_regular_file('file1', contents=b'123456') + os.link(os.path.join(self.input_path, 'file1'), os.path.join(self.input_path, 'file2')) + self.cmd('init', '--encryption=none', self.repository_location) + self.cmd('create', '--paths-from-stdin', self.repository_location + '::test', + input=b'input/file1\ninput/file1\ninput/file2\n') + mountpoint = os.path.join(self.tmpdir, 'mountpoint') + if sys.platform == 'darwin': + ignore_perms = ['-o', 'ignore_permissions,defer_permissions'] + else: + ignore_perms = ['-o', 'ignore_permissions'] + with self.fuse_mount(self.repository_location + '::test', mountpoint, *ignore_perms), \ + changedir(mountpoint): + assert os.stat('input/file1').st_nlink == 2 + assert os.stat('input/file2').st_nlink == 2 + assert open('input/file1', 'rb').read() == b'123456' + def test_extract_include_exclude(self): self.cmd('init', '--encryption=repokey', self.repository_location) self.create_regular_file('file1', size=1024 * 80) @@ -4410,6 +4454,20 @@ def test_extract_hardlinks_tar(self): assert os.stat('input/dir1/aaaa').st_nlink == 2 assert os.stat('input/dir1/source2').st_nlink == 2 + @requires_hardlinks + def test_export_tar_duplicate_hardlink_item(self): + # an archive can have the same hard link item twice (here: the same path given twice via stdin). + # export-tar must not add the duplicate as a hard link to itself (#10393). + self.create_regular_file('file1', contents=b'123456') + os.link(os.path.join(self.input_path, 'file1'), os.path.join(self.input_path, 'file2')) + self.cmd('init', '--encryption=none', self.repository_location) + self.cmd('create', '--paths-from-stdin', self.repository_location + '::test', + input=b'input/file1\ninput/file1\ninput/file2\n') + self.cmd('export-tar', self.repository_location + '::test', 'output.tar') + with tarfile.open('output.tar') as tar: + members = [(m.name, m.type, m.linkname) for m in tar] + assert members == [('input/file1', tarfile.REGTYPE, ''), ('input/file2', tarfile.LNKTYPE, 'input/file1')] + def test_import_tar(self): self.create_test_files() os.unlink('input/flagfile')