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
6 changes: 6 additions & 0 deletions src/borg/archive.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/borg/archiver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/borg/fuse.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
58 changes: 58 additions & 0 deletions src/borg/testsuite/archiver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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')
Expand Down
Loading