Skip to content

Commit 298b90b

Browse files
committed
Update ReadTarFS to work on cyclic and dangling symlinks
1 parent a49deed commit 298b90b

File tree

2 files changed

+77
-17
lines changed

2 files changed

+77
-17
lines changed

fs/tarfs.py

+29-16
Original file line numberDiff line numberDiff line change
@@ -318,25 +318,33 @@ def _directory_entries(self):
318318
return self._directory_cache
319319

320320
def _follow_symlink(self, entry):
321-
"""Follow an symlink `TarInfo` to find a concrete entry."""
321+
"""Follow an symlink `TarInfo` to find a concrete entry.
322+
323+
Returns ``None`` if the symlink is dangling.
324+
"""
325+
done = set()
322326
_entry = entry
323327
while _entry.issym():
324328
linkname = normpath(
325329
join(dirname(self._decode(_entry.name)), self._decode(_entry.linkname))
326330
)
327331
resolved = self._resolve(linkname)
328332
if resolved is None:
329-
raise errors.ResourceNotFound(linkname)
333+
return None
334+
done.add(_entry)
330335
_entry = self._directory_entries[resolved]
336+
# if we already saw this symlink, then we are following cyclic
337+
# symlinks and we should break the loop
338+
if _entry in done:
339+
return None
331340

332341
return _entry
333342

334343
def _resolve(self, path):
335344
"""Replace path components that are symlinks with concrete components.
336345
337-
Returns:
338-
339-
346+
Returns ``None`` when the path could not be resolved to an existing
347+
entry in the archive.
340348
"""
341349
if path in self._directory_entries or not path:
342350
return path
@@ -406,7 +414,7 @@ def getinfo(self, path, namespaces=None):
406414
target = normpath(join(
407415
dirname(self._decode(member.name)),
408416
self._decode(member.linkname),
409-
)) # type: Option[Text]
417+
)) # type: Optional[Text]
410418
else:
411419
target = None
412420
raw_info["link"] = {"target": target}
@@ -441,17 +449,17 @@ def isdir(self, path):
441449
_path = relpath(self.validatepath(path))
442450
realpath = self._resolve(_path)
443451
if realpath is not None:
444-
entry = self._directory_entries[realpath]
445-
return self._follow_symlink(entry).isdir()
452+
entry = self._follow_symlink(self._directory_entries[realpath])
453+
return False if entry is None else entry.isdir()
446454
else:
447455
return False
448456

449457
def isfile(self, path):
450458
_path = relpath(self.validatepath(path))
451459
realpath = self._resolve(_path)
452460
if realpath is not None:
453-
entry = self._directory_entries[realpath]
454-
return self._follow_symlink(entry).isfile()
461+
entry = self._follow_symlink(self._directory_entries[realpath])
462+
return False if entry is None else entry.isfile()
455463
else:
456464
return False
457465

@@ -480,12 +488,12 @@ def listdir(self, path):
480488
elif realpath:
481489
target = self._follow_symlink(self._directory_entries[realpath])
482490
# check the path is either a symlink mapping to a directory or a directory
483-
if target.isdir():
484-
base = target.name
485-
elif target.issym():
486-
base = target.linkname
487-
else:
491+
if target is None:
492+
raise errors.ResourceNotFound(path)
493+
elif not target.isdir():
488494
raise errors.DirectoryExpected(path)
495+
else:
496+
base = target.name
489497
else:
490498
base = ""
491499

@@ -515,11 +523,16 @@ def openbin(self, path, mode="r", buffering=-1, **options):
515523
if "w" in mode or "+" in mode or "a" in mode:
516524
raise errors.ResourceReadOnly(path)
517525

518-
# check the path actually resolves after following symlinks
526+
# check the path actually resolves after following symlink components
519527
_realpath = self._resolve(_path)
520528
if _realpath is None:
521529
raise errors.ResourceNotFound(path)
522530

531+
# get the entry at the resolved path and follow all symlinks
532+
entry = self._follow_symlink(self._directory_entries[_realpath])
533+
if entry is None:
534+
raise errors.ResourceNotFound(path)
535+
523536
# TarFile.extractfile returns None if the entry is not a file
524537
# neither a file nor a symlink
525538
reader = self._tar.extractfile(self._directory_entries[_realpath])

tests/test_tarfs.py

+48-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from fs.compress import write_tar
1515
from fs.opener import open_fs
1616
from fs.opener.errors import NotWriteable
17-
from fs.errors import NoURL
17+
from fs.errors import NoURL, ResourceNotFound
1818
from fs.test import FSTestCases
1919

2020
from .test_archives import ArchiveTestCases
@@ -415,6 +415,53 @@ def test_listdir(self):
415415
self.assertEqual(sorted(self.fs.listdir("spam")), ["bar.txt", "baz.txt"])
416416

417417

418+
class TestBrokenSymlinks(unittest.TestCase):
419+
420+
@classmethod
421+
def setUpClass(cls):
422+
cls.tmpfs = open_fs("temp://")
423+
424+
@classmethod
425+
def tearDownClass(cls):
426+
cls.tmpfs.close()
427+
428+
def setUp(self):
429+
def _info(name, **kwargs):
430+
info = tarfile.TarInfo(name)
431+
for k, v in kwargs.items():
432+
setattr(info, k, v)
433+
return info
434+
435+
# /foo
436+
# /foo/baz.txt -> /foo/bar.txt
437+
# /spam -> /eggs
438+
# /eggs -> /spam
439+
440+
self.tempfile = self.tmpfs.open("test.tar", "wb+")
441+
with tarfile.open(mode="w", fileobj=self.tempfile) as tf:
442+
tf.addfile(_info("foo", type=tarfile.DIRTYPE))
443+
tf.addfile(_info("foo/baz.txt", type=tarfile.SYMTYPE, linkname="bar.txt"))
444+
tf.addfile(_info("spam", type=tarfile.SYMTYPE, linkname="eggs"))
445+
tf.addfile(_info("eggs", type=tarfile.SYMTYPE, linkname="spam"))
446+
self.tempfile.seek(0)
447+
self.fs = tarfs.TarFS(self.tempfile)
448+
449+
def tearDown(self):
450+
self.fs.close()
451+
self.tempfile.close()
452+
453+
def test_dangling(self):
454+
self.assertFalse(self.fs.isfile("foo/baz.txt"))
455+
self.assertFalse(self.fs.isdir("foo/baz.txt"))
456+
self.assertRaises(ResourceNotFound, self.fs.openbin, "foo/baz.txt")
457+
self.assertRaises(ResourceNotFound, self.fs.listdir, "foo/baz.txt")
458+
459+
def test_cyclic(self):
460+
self.assertFalse(self.fs.isfile("spam"))
461+
self.assertFalse(self.fs.isdir("spam"))
462+
self.assertRaises(ResourceNotFound, self.fs.openbin, "spam")
463+
self.assertRaises(ResourceNotFound, self.fs.listdir, "spam")
464+
418465
class TestReadTarFSMem(TestReadTarFS):
419466
def make_source_fs(self):
420467
return open_fs("mem://")

0 commit comments

Comments
 (0)