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')