@@ -3357,6 +3357,39 @@ class TestExtractionFilters(unittest.TestCase):
33573357 # The destination for the extraction, within `outerdir`
33583358 destdir = outerdir / 'dest'
33593359
3360+ @classmethod
3361+ def setUpClass (cls ):
3362+ # Posix and Windows have different pathname resolution:
3363+ # either symlink or a '..' component resolve first.
3364+ # Let's see which we are on.
3365+ if os_helper .can_symlink ():
3366+ testpath = os .path .join (TEMPDIR , 'resolution_test' )
3367+ os .mkdir (testpath )
3368+
3369+ # testpath/current links to `.` which is all of:
3370+ # - `testpath`
3371+ # - `testpath/current`
3372+ # - `testpath/current/current`
3373+ # - etc.
3374+ os .symlink ('.' , os .path .join (testpath , 'current' ))
3375+
3376+ # we'll test where `testpath/current/../file` ends up
3377+ with open (os .path .join (testpath , 'current' , '..' , 'file' ), 'w' ):
3378+ pass
3379+
3380+ if os .path .exists (os .path .join (testpath , 'file' )):
3381+ # Windows collapses 'current\..' to '.' first, leaving
3382+ # 'testpath\file'
3383+ cls .dotdot_resolves_early = True
3384+ elif os .path .exists (os .path .join (testpath , '..' , 'file' )):
3385+ # Posix resolves 'current' to '.' first, leaving
3386+ # 'testpath/../file'
3387+ cls .dotdot_resolves_early = False
3388+ else :
3389+ raise AssertionError ('Could not determine link resolution' )
3390+ else :
3391+ cls .dotdot_resolves_early = False
3392+
33603393 @contextmanager
33613394 def check_context (self , tar , filter , * , check_flag = True , ignored_trees = ()):
33623395 """Extracts `tar` to `self.destdir` and allows checking the result
@@ -3538,10 +3571,19 @@ def test_parent_symlink(self):
35383571 + "which is outside the destination" )
35393572
35403573 with self .check_context (arc .open (), 'data' ):
3541- self .expect_exception (
3542- tarfile .LinkOutsideDestinationError ,
3543- """'parent' would link to ['"].*outerdir['"], """
3544- + "which is outside the destination" )
3574+ if self .dotdot_resolves_early :
3575+ # 'current/../..' normalises to '..', which is rejected.
3576+ self .expect_exception (
3577+ tarfile .LinkOutsideDestinationError ,
3578+ """'parent' would link to ['"].*outerdir['"], """
3579+ + "which is outside the destination" )
3580+ else :
3581+ # 'current/..' normalises to '.'; the rewritten link is
3582+ # created and 'parent/evil' lands harmlessly inside the
3583+ # destination.
3584+ self .expect_file ('current' , symlink_to = '.' )
3585+ self .expect_file ('parent' , symlink_to = '.' )
3586+ self .expect_file ('evil' )
35453587
35463588 else :
35473589 # No symlink support. The symlinks are ignored.
@@ -3628,35 +3670,6 @@ def test_parent_symlink2(self):
36283670 # Test interplaying symlinks
36293671 # Inspired by 'dirsymlink2b' in jwilk/traversal-archives
36303672
3631- # Posix and Windows have different pathname resolution:
3632- # either symlink or a '..' component resolve first.
3633- # Let's see which we are on.
3634- if os_helper .can_symlink ():
3635- testpath = os .path .join (TEMPDIR , 'resolution_test' )
3636- os .mkdir (testpath )
3637-
3638- # testpath/current links to `.` which is all of:
3639- # - `testpath`
3640- # - `testpath/current`
3641- # - `testpath/current/current`
3642- # - etc.
3643- os .symlink ('.' , os .path .join (testpath , 'current' ))
3644-
3645- # we'll test where `testpath/current/../file` ends up
3646- with open (os .path .join (testpath , 'current' , '..' , 'file' ), 'w' ):
3647- pass
3648-
3649- if os .path .exists (os .path .join (testpath , 'file' )):
3650- # Windows collapses 'current\..' to '.' first, leaving
3651- # 'testpath\file'
3652- dotdot_resolves_early = True
3653- elif os .path .exists (os .path .join (testpath , '..' , 'file' )):
3654- # Posix resolves 'current' to '.' first, leaving
3655- # 'testpath/../file'
3656- dotdot_resolves_early = False
3657- else :
3658- raise AssertionError ('Could not determine link resolution' )
3659-
36603673 with ArchiveMaker () as arc :
36613674
36623675 # `current` links to `.` which is both the destination directory
@@ -3692,7 +3705,7 @@ def test_parent_symlink2(self):
36923705
36933706 with self .check_context (arc .open (), 'data' ):
36943707 if os_helper .can_symlink ():
3695- if dotdot_resolves_early :
3708+ if self . dotdot_resolves_early :
36963709 # Fail when extracting a file outside destination
36973710 self .expect_exception (
36983711 tarfile .OutsideDestinationError ,
@@ -3809,6 +3822,72 @@ def test_sly_relative2(self):
38093822 + """['"].*moo['"], which is outside the """
38103823 + "destination" )
38113824
3825+ @os_helper .skip_unless_symlink
3826+ def test_normpath_realpath_mismatch (self ):
3827+ # The link-target check must validate the value that will actually
3828+ # be written to disk (the normalised linkname), not the original.
3829+ # Here 'a' is a symlink to a deep nonexistent path, so realpath()
3830+ # of 'a/../../...' stays inside the destination while normpath()
3831+ # collapses 'a/..' lexically and escapes.
3832+ depth = len (self .destdir .parts ) + 5
3833+ deep = '/' .join (f'p{ i } ' for i in range (depth ))
3834+ sneaky = 'a/' + '../' * depth + 'flag'
3835+ for kind in 'symlink_to' , 'hardlink_to' :
3836+ with self .subTest (kind ):
3837+ with ArchiveMaker () as arc :
3838+ arc .add ('a' , symlink_to = deep )
3839+ arc .add ('escape' , ** {kind : sneaky })
3840+ with self .check_context (arc .open (), 'data' ):
3841+ self .expect_exception (
3842+ tarfile .LinkOutsideDestinationError )
3843+
3844+ @os_helper .skip_unless_symlink
3845+ def test_symlink_trailing_slash (self ):
3846+ # A trailing slash on a symlink member's name must not cause the
3847+ # link target to be resolved relative to the wrong directory.
3848+ with ArchiveMaker () as arc :
3849+ t = tarfile .TarInfo ('x/' )
3850+ t .type = tarfile .SYMTYPE
3851+ t .linkname = '..'
3852+ arc .tar_w .addfile (t )
3853+ arc .add ('x/escaped' , content = 'hi' )
3854+
3855+ with self .check_context (arc .open (), 'data' ):
3856+ self .expect_exception (tarfile .LinkOutsideDestinationError )
3857+
3858+ @os_helper .skip_unless_symlink
3859+ def test_link_at_destination (self ):
3860+ # A link member whose name resolves to the destination directory
3861+ # itself must be rejected: otherwise the destination is replaced
3862+ # by a symlink and later members can be redirected through it.
3863+ for name in '' , '.' , './' :
3864+ with ArchiveMaker () as arc :
3865+ t = tarfile .TarInfo (name )
3866+ t .type = tarfile .SYMTYPE
3867+ t .linkname = '.'
3868+ arc .tar_w .addfile (t )
3869+
3870+ with self .check_context (arc .open (), 'data' ):
3871+ self .expect_exception (tarfile .OutsideDestinationError )
3872+
3873+ @os_helper .skip_unless_symlink
3874+ def test_empty_name_symlink_chain (self ):
3875+ # Regression test for a chain of empty-named symlinks that
3876+ # incrementally redirects the destination outwards.
3877+ with ArchiveMaker () as arc :
3878+ for name , target in [('' , '' ), ('a/' , '..' ),
3879+ ('' , 'dummy' ), ('' , 'a' ),
3880+ ('b/' , '..' ),
3881+ ('' , 'dummy' ), ('' , 'a/b' )]:
3882+ t = tarfile .TarInfo (name )
3883+ t .type = tarfile .SYMTYPE
3884+ t .linkname = target
3885+ arc .tar_w .addfile (t )
3886+ arc .add ('escaped' , content = 'hi' )
3887+
3888+ with self .check_context (arc .open (), 'data' ):
3889+ self .expect_exception (tarfile .FilterError )
3890+
38123891 def test_deep_symlink (self ):
38133892 # Test that symlinks and hardlinks inside a directory
38143893 # point to the correct file (`target` of size 3).
0 commit comments