From 072e99a0b8674f3782472e92f59ae9bc7eb529d4 Mon Sep 17 00:00:00 2001 From: eabdullin Date: Tue, 11 Nov 2025 15:44:51 +0000 Subject: [PATCH] import UBI python3.9-3.9.23-2.el9 --- .gitignore | 2 +- .python3.9.metadata | 2 +- ...-and-in-domain-names-for-parsed-urls.patch | 119 - SOURCES/00465-tarfile-cves.patch | 2351 ----------------- SOURCES/Python-3.9.21.tar.xz.asc | 16 - SOURCES/Python-3.9.23.tar.xz.asc | 16 + SPECS/python3.9.spec | 32 +- 7 files changed, 30 insertions(+), 2508 deletions(-) delete mode 100644 SOURCES/00450-cve-2025-0938-disallow-square-brackets-and-in-domain-names-for-parsed-urls.patch delete mode 100644 SOURCES/00465-tarfile-cves.patch delete mode 100644 SOURCES/Python-3.9.21.tar.xz.asc create mode 100644 SOURCES/Python-3.9.23.tar.xz.asc diff --git a/.gitignore b/.gitignore index 1e7e31b..ad441f6 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1 @@ -SOURCES/Python-3.9.21.tar.xz +SOURCES/Python-3.9.23.tar.xz diff --git a/.python3.9.metadata b/.python3.9.metadata index 1c47439..6062d60 100644 --- a/.python3.9.metadata +++ b/.python3.9.metadata @@ -1 +1 @@ -d968a953f19c6fc3bf54b5ded5c06852197ebddc SOURCES/Python-3.9.21.tar.xz +73d07237b70b19e4cd530bbc204cccd668ec05d4 SOURCES/Python-3.9.23.tar.xz diff --git a/SOURCES/00450-cve-2025-0938-disallow-square-brackets-and-in-domain-names-for-parsed-urls.patch b/SOURCES/00450-cve-2025-0938-disallow-square-brackets-and-in-domain-names-for-parsed-urls.patch deleted file mode 100644 index a96f8e6..0000000 --- a/SOURCES/00450-cve-2025-0938-disallow-square-brackets-and-in-domain-names-for-parsed-urls.patch +++ /dev/null @@ -1,119 +0,0 @@ -From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 -From: Seth Michael Larson -Date: Fri, 31 Jan 2025 11:41:34 -0600 -Subject: [PATCH] 00450: CVE-2025-0938: Disallow square brackets ([ and ]) in - domain names for parsed URLs - -Co-authored-by: Peter Bierma ---- - Lib/test/test_urlparse.py | 37 ++++++++++++++++++- - Lib/urllib/parse.py | 20 +++++++++- - ...-01-28-14-08-03.gh-issue-105704.EnhHxu.rst | 4 ++ - 3 files changed, 58 insertions(+), 3 deletions(-) - create mode 100644 Misc/NEWS.d/next/Security/2025-01-28-14-08-03.gh-issue-105704.EnhHxu.rst - -diff --git a/Lib/test/test_urlparse.py b/Lib/test/test_urlparse.py -index 6f7d40c212..083d08b22e 100644 ---- a/Lib/test/test_urlparse.py -+++ b/Lib/test/test_urlparse.py -@@ -1146,16 +1146,51 @@ class UrlParseTestCase(unittest.TestCase): - self.assertRaises(ValueError, urllib.parse.urlsplit, 'Scheme://user@[0439:23af::2309::fae7:1234]/Path?Query') - self.assertRaises(ValueError, urllib.parse.urlsplit, 'Scheme://user@[0439:23af:2309::fae7:1234:2342:438e:192.0.2.146]/Path?Query') - self.assertRaises(ValueError, urllib.parse.urlsplit, 'Scheme://user@]v6a.ip[/Path') -+ self.assertRaises(ValueError, urllib.parse.urlsplit, 'scheme://prefix.[v6a.ip]') -+ self.assertRaises(ValueError, urllib.parse.urlsplit, 'scheme://[v6a.ip].suffix') -+ self.assertRaises(ValueError, urllib.parse.urlsplit, 'scheme://prefix.[v6a.ip]/') -+ self.assertRaises(ValueError, urllib.parse.urlsplit, 'scheme://[v6a.ip].suffix/') -+ self.assertRaises(ValueError, urllib.parse.urlsplit, 'scheme://prefix.[v6a.ip]?') -+ self.assertRaises(ValueError, urllib.parse.urlsplit, 'scheme://[v6a.ip].suffix?') -+ self.assertRaises(ValueError, urllib.parse.urlsplit, 'scheme://prefix.[::1]') -+ self.assertRaises(ValueError, urllib.parse.urlsplit, 'scheme://[::1].suffix') -+ self.assertRaises(ValueError, urllib.parse.urlsplit, 'scheme://prefix.[::1]/') -+ self.assertRaises(ValueError, urllib.parse.urlsplit, 'scheme://[::1].suffix/') -+ self.assertRaises(ValueError, urllib.parse.urlsplit, 'scheme://prefix.[::1]?') -+ self.assertRaises(ValueError, urllib.parse.urlsplit, 'scheme://[::1].suffix?') -+ self.assertRaises(ValueError, urllib.parse.urlsplit, 'scheme://prefix.[::1]:a') -+ self.assertRaises(ValueError, urllib.parse.urlsplit, 'scheme://[::1].suffix:a') -+ self.assertRaises(ValueError, urllib.parse.urlsplit, 'scheme://prefix.[::1]:a1') -+ self.assertRaises(ValueError, urllib.parse.urlsplit, 'scheme://[::1].suffix:a1') -+ self.assertRaises(ValueError, urllib.parse.urlsplit, 'scheme://prefix.[::1]:1a') -+ self.assertRaises(ValueError, urllib.parse.urlsplit, 'scheme://[::1].suffix:1a') -+ self.assertRaises(ValueError, urllib.parse.urlsplit, 'scheme://prefix.[::1]:') -+ self.assertRaises(ValueError, urllib.parse.urlsplit, 'scheme://[::1].suffix:/') -+ self.assertRaises(ValueError, urllib.parse.urlsplit, 'scheme://prefix.[::1]:?') -+ self.assertRaises(ValueError, urllib.parse.urlsplit, 'scheme://user@prefix.[v6a.ip]') -+ self.assertRaises(ValueError, urllib.parse.urlsplit, 'scheme://user@[v6a.ip].suffix') -+ self.assertRaises(ValueError, urllib.parse.urlsplit, 'scheme://[v6a.ip') -+ self.assertRaises(ValueError, urllib.parse.urlsplit, 'scheme://v6a.ip]') -+ self.assertRaises(ValueError, urllib.parse.urlsplit, 'scheme://]v6a.ip[') -+ self.assertRaises(ValueError, urllib.parse.urlsplit, 'scheme://]v6a.ip') -+ self.assertRaises(ValueError, urllib.parse.urlsplit, 'scheme://v6a.ip[') -+ self.assertRaises(ValueError, urllib.parse.urlsplit, 'scheme://prefix.[v6a.ip') -+ self.assertRaises(ValueError, urllib.parse.urlsplit, 'scheme://v6a.ip].suffix') -+ self.assertRaises(ValueError, urllib.parse.urlsplit, 'scheme://prefix]v6a.ip[suffix') -+ self.assertRaises(ValueError, urllib.parse.urlsplit, 'scheme://prefix]v6a.ip') -+ self.assertRaises(ValueError, urllib.parse.urlsplit, 'scheme://v6a.ip[suffix') - - def test_splitting_bracketed_hosts(self): -- p1 = urllib.parse.urlsplit('scheme://user@[v6a.ip]/path?query') -+ p1 = urllib.parse.urlsplit('scheme://user@[v6a.ip]:1234/path?query') - self.assertEqual(p1.hostname, 'v6a.ip') - self.assertEqual(p1.username, 'user') - self.assertEqual(p1.path, '/path') -+ self.assertEqual(p1.port, 1234) - p2 = urllib.parse.urlsplit('scheme://user@[0439:23af:2309::fae7%test]/path?query') - self.assertEqual(p2.hostname, '0439:23af:2309::fae7%test') - self.assertEqual(p2.username, 'user') - self.assertEqual(p2.path, '/path') -+ self.assertIs(p2.port, None) - p3 = urllib.parse.urlsplit('scheme://user@[0439:23af:2309::fae7:1234:192.0.2.146%test]/path?query') - self.assertEqual(p3.hostname, '0439:23af:2309::fae7:1234:192.0.2.146%test') - self.assertEqual(p3.username, 'user') -diff --git a/Lib/urllib/parse.py b/Lib/urllib/parse.py -index 9d37dcaa90..fb8f7f1ea8 100644 ---- a/Lib/urllib/parse.py -+++ b/Lib/urllib/parse.py -@@ -443,6 +443,23 @@ def _checknetloc(netloc): - raise ValueError("netloc '" + netloc + "' contains invalid " + - "characters under NFKC normalization") - -+def _check_bracketed_netloc(netloc): -+ # Note that this function must mirror the splitting -+ # done in NetlocResultMixins._hostinfo(). -+ hostname_and_port = netloc.rpartition('@')[2] -+ before_bracket, have_open_br, bracketed = hostname_and_port.partition('[') -+ if have_open_br: -+ # No data is allowed before a bracket. -+ if before_bracket: -+ raise ValueError("Invalid IPv6 URL") -+ hostname, _, port = bracketed.partition(']') -+ # No data is allowed after the bracket but before the port delimiter. -+ if port and not port.startswith(":"): -+ raise ValueError("Invalid IPv6 URL") -+ else: -+ hostname, _, port = hostname_and_port.partition(':') -+ _check_bracketed_host(hostname) -+ - # Valid bracketed hosts are defined in - # https://www.rfc-editor.org/rfc/rfc3986#page-49 and https://url.spec.whatwg.org/ - def _check_bracketed_host(hostname): -@@ -506,8 +523,7 @@ def urlsplit(url, scheme='', allow_fragments=True): - (']' in netloc and '[' not in netloc)): - raise ValueError("Invalid IPv6 URL") - if '[' in netloc and ']' in netloc: -- bracketed_host = netloc.partition('[')[2].partition(']')[0] -- _check_bracketed_host(bracketed_host) -+ _check_bracketed_netloc(netloc) - if allow_fragments and '#' in url: - url, fragment = url.split('#', 1) - if '?' in url: -diff --git a/Misc/NEWS.d/next/Security/2025-01-28-14-08-03.gh-issue-105704.EnhHxu.rst b/Misc/NEWS.d/next/Security/2025-01-28-14-08-03.gh-issue-105704.EnhHxu.rst -new file mode 100644 -index 0000000000..bff1bc6b0d ---- /dev/null -+++ b/Misc/NEWS.d/next/Security/2025-01-28-14-08-03.gh-issue-105704.EnhHxu.rst -@@ -0,0 +1,4 @@ -+When using :func:`urllib.parse.urlsplit` and :func:`urllib.parse.urlparse` host -+parsing would not reject domain names containing square brackets (``[`` and -+``]``). Square brackets are only valid for IPv6 and IPvFuture hosts according to -+`RFC 3986 Section 3.2.2 `__. diff --git a/SOURCES/00465-tarfile-cves.patch b/SOURCES/00465-tarfile-cves.patch deleted file mode 100644 index 8668b14..0000000 --- a/SOURCES/00465-tarfile-cves.patch +++ /dev/null @@ -1,2351 +0,0 @@ -From 9e8fc9d77e234cf4e57f2a6001a7eb6e2078c05f Mon Sep 17 00:00:00 2001 -From: =?UTF-8?q?=C5=81ukasz=20Langa?= -Date: Mon, 2 Jun 2025 18:28:09 +0200 -Subject: [PATCH 1/2] bpo-43757: Make pathlib use os.path.realpath() to resolve - symlinks in a path (GH-25264) (GH-135035) - -Also adds a new "strict" argument to realpath() to avoid changing the default behaviour of pathlib while sharing the implementation. - -(cherry-picked from commit baecfbd849dbf42360d3a84af6cc13160838f24d) - -Co-authored-by: Barney Gale ---- - Doc/library/os.path.rst | 18 ++- - Lib/ntpath.py | 4 +- - Lib/pathlib.py | 148 +++++------------- - Lib/posixpath.py | 26 ++- - Lib/test/test_ntpath.py | 60 ++++++- - Lib/test/test_posixpath.py | 57 ++++++- - .../2021-04-08-22-11-27.bpo-25264.b33fa0.rst | 3 + - 7 files changed, 192 insertions(+), 124 deletions(-) - create mode 100644 Misc/NEWS.d/next/Library/2021-04-08-22-11-27.bpo-25264.b33fa0.rst - -diff --git a/Doc/library/os.path.rst b/Doc/library/os.path.rst -index 97bb684..19a5ae0 100644 ---- a/Doc/library/os.path.rst -+++ b/Doc/library/os.path.rst -@@ -345,15 +345,24 @@ the :mod:`glob` module.) - Accepts a :term:`path-like object`. - - --.. function:: realpath(path) -+.. function:: realpath(path, *, strict=False) - - Return the canonical path of the specified filename, eliminating any symbolic - links encountered in the path (if they are supported by the operating - system). - -+ If a path doesn't exist or a symlink loop is encountered, and *strict* is -+ ``True``, :exc:`OSError` is raised. If *strict* is ``False``, the path is -+ resolved as far as possible and any remainder is appended without checking -+ whether it exists. -+ - .. note:: -- When symbolic link cycles occur, the returned path will be one member of -- the cycle, but no guarantee is made about which member that will be. -+ This function emulates the operating system's procedure for making a path -+ canonical, which differs slightly between Windows and UNIX with respect -+ to how links and subsequent path components interact. -+ -+ Operating system APIs make paths canonical as needed, so it's not -+ normally necessary to call this function. - - .. versionchanged:: 3.6 - Accepts a :term:`path-like object`. -@@ -361,6 +370,9 @@ the :mod:`glob` module.) - .. versionchanged:: 3.8 - Symbolic links and junctions are now resolved on Windows. - -+ .. versionchanged:: 3.9.23 -+ The *strict* parameter was added. -+ - - .. function:: relpath(path, start=os.curdir) - -diff --git a/Lib/ntpath.py b/Lib/ntpath.py -index 6f77177..92b46e2 100644 ---- a/Lib/ntpath.py -+++ b/Lib/ntpath.py -@@ -622,7 +622,7 @@ else: - tail = join(name, tail) if tail else name - return tail - -- def realpath(path): -+ def realpath(path, *, strict=False): - path = normpath(path) - if isinstance(path, bytes): - prefix = b'\\\\?\\' -@@ -647,6 +647,8 @@ else: - path = _getfinalpathname(path) - initial_winerror = 0 - except OSError as ex: -+ if strict: -+ raise - initial_winerror = ex.winerror - path = _getfinalpathname_nonstrict(path) - # The path returned by _getfinalpathname will always start with \\?\ - -diff --git a/Lib/pathlib.py b/Lib/pathlib.py -index 7aeda14..1185a24 100644 ---- a/Lib/pathlib.py -+++ b/Lib/pathlib.py -@@ -14,15 +14,6 @@ from urllib.parse import quote_from_bytes as urlquote_from_bytes - - - supports_symlinks = True --if os.name == 'nt': -- import nt -- if sys.getwindowsversion()[:2] >= (6, 0): -- from nt import _getfinalpathname -- else: -- supports_symlinks = False -- _getfinalpathname = None --else: -- nt = None - - - __all__ = [ -@@ -34,14 +25,17 @@ __all__ = [ - # Internals - # - -+_WINERROR_NOT_READY = 21 # drive exists but is not accessible -+_WINERROR_INVALID_NAME = 123 # fix for bpo-35306 -+_WINERROR_CANT_RESOLVE_FILENAME = 1921 # broken symlink pointing to itself -+ - # EBADF - guard against macOS `stat` throwing EBADF - _IGNORED_ERROS = (ENOENT, ENOTDIR, EBADF, ELOOP) - - _IGNORED_WINERRORS = ( -- 21, # ERROR_NOT_READY - drive exists but is not accessible -- 123, # ERROR_INVALID_NAME - fix for bpo-35306 -- 1921, # ERROR_CANT_RESOLVE_FILENAME - fix for broken symlink pointing to itself --) -+ _WINERROR_NOT_READY, -+ _WINERROR_INVALID_NAME, -+ _WINERROR_CANT_RESOLVE_FILENAME) - - def _ignore_error(exception): - return (getattr(exception, 'errno', None) in _IGNORED_ERROS or -@@ -200,30 +194,6 @@ class _WindowsFlavour(_Flavour): - def compile_pattern(self, pattern): - return re.compile(fnmatch.translate(pattern), re.IGNORECASE).fullmatch - -- def resolve(self, path, strict=False): -- s = str(path) -- if not s: -- return os.getcwd() -- previous_s = None -- if _getfinalpathname is not None: -- if strict: -- return self._ext_to_normal(_getfinalpathname(s)) -- else: -- tail_parts = [] # End of the path after the first one not found -- while True: -- try: -- s = self._ext_to_normal(_getfinalpathname(s)) -- except FileNotFoundError: -- previous_s = s -- s, tail = os.path.split(s) -- tail_parts.append(tail) -- if previous_s == s: -- return path -- else: -- return os.path.join(s, *reversed(tail_parts)) -- # Means fallback on absolute -- return None -- - def _split_extended_path(self, s, ext_prefix=ext_namespace_prefix): - prefix = '' - if s.startswith(ext_prefix): -@@ -234,10 +204,6 @@ class _WindowsFlavour(_Flavour): - s = '\\' + s[3:] - return prefix, s - -- def _ext_to_normal(self, s): -- # Turn back an extended path into a normal DOS-like path -- return self._split_extended_path(s)[1] -- - def is_reserved(self, parts): - # NOTE: the rules for reserved names seem somewhat complicated - # (e.g. r"..\NUL" is reserved but not r"foo\NUL" if "foo" does not -@@ -324,54 +290,6 @@ class _PosixFlavour(_Flavour): - def compile_pattern(self, pattern): - return re.compile(fnmatch.translate(pattern)).fullmatch - -- def resolve(self, path, strict=False): -- sep = self.sep -- accessor = path._accessor -- seen = {} -- def _resolve(path, rest): -- if rest.startswith(sep): -- path = '' -- -- for name in rest.split(sep): -- if not name or name == '.': -- # current dir -- continue -- if name == '..': -- # parent dir -- path, _, _ = path.rpartition(sep) -- continue -- if path.endswith(sep): -- newpath = path + name -- else: -- newpath = path + sep + name -- if newpath in seen: -- # Already seen this path -- path = seen[newpath] -- if path is not None: -- # use cached value -- continue -- # The symlink is not resolved, so we must have a symlink loop. -- raise RuntimeError("Symlink loop from %r" % newpath) -- # Resolve the symbolic link -- try: -- target = accessor.readlink(newpath) -- except OSError as e: -- if e.errno != EINVAL and strict: -- raise -- # Not a symlink, or non-strict mode. We just leave the path -- # untouched. -- path = newpath -- else: -- seen[newpath] = None # not resolved symlink -- path = _resolve(path, target) -- seen[newpath] = path # resolved symlink -- -- return path -- # NOTE: according to POSIX, getcwd() cannot contain path components -- # which are symlinks. -- base = '' if path.is_absolute() else os.getcwd() -- return _resolve(base, str(path)) or sep -- - def is_reserved(self, parts): - return False - -@@ -443,17 +361,11 @@ class _NormalAccessor(_Accessor): - - replace = os.replace - -- if nt: -- if supports_symlinks: -- symlink = os.symlink -- else: -- def symlink(a, b, target_is_directory): -- raise NotImplementedError("symlink() not available on this system") -+ if hasattr(os, "symlink"): -+ symlink = os.symlink - else: -- # Under POSIX, os.symlink() takes two args -- @staticmethod -- def symlink(a, b, target_is_directory): -- return os.symlink(a, b) -+ def symlink(self, src, dst, target_is_directory=False): -+ raise NotImplementedError("os.symlink() not available on this system") - - utime = os.utime - -@@ -475,6 +387,12 @@ class _NormalAccessor(_Accessor): - except ImportError: - raise NotImplementedError("Path.group() is unsupported on this system") - -+ getcwd = os.getcwd -+ -+ expanduser = staticmethod(os.path.expanduser) -+ -+ realpath = staticmethod(os.path.realpath) -+ - - _normal_accessor = _NormalAccessor() - -@@ -1212,17 +1130,27 @@ class Path(PurePath): - normalizing it (for example turning slashes into backslashes under - Windows). - """ -- s = self._flavour.resolve(self, strict=strict) -- if s is None: -- # No symlink resolution => for consistency, raise an error if -- # the path doesn't exist or is forbidden -- self.stat() -- s = str(self.absolute()) -- # Now we have no symlinks in the path, it's safe to normalize it. -- normed = self._flavour.pathmod.normpath(s) -- obj = self._from_parts((normed,), init=False) -- obj._init(template=self) -- return obj -+ -+ def check_eloop(e): -+ winerror = getattr(e, 'winerror', 0) -+ if e.errno == ELOOP or winerror == _WINERROR_CANT_RESOLVE_FILENAME: -+ raise RuntimeError("Symlink loop from %r" % e.filename) -+ -+ try: -+ s = self._accessor.realpath(self, strict=strict) -+ except OSError as e: -+ check_eloop(e) -+ raise -+ p = self._from_parts((s,)) -+ -+ # In non-strict mode, realpath() doesn't raise on symlink loops. -+ # Ensure we get an exception by calling stat() -+ if not strict: -+ try: -+ p.stat() -+ except OSError as e: -+ check_eloop(e) -+ return p - - def stat(self): - """ -diff --git a/Lib/posixpath.py b/Lib/posixpath.py -index af2814b..eb80fb9 100644 ---- a/Lib/posixpath.py -+++ b/Lib/posixpath.py -@@ -385,16 +385,16 @@ def abspath(path): - # Return a canonical path (i.e. the absolute location of a file on the - # filesystem). - --def realpath(filename): -+def realpath(filename, *, strict=False): - """Return the canonical path of the specified filename, eliminating any - symbolic links encountered in the path.""" - filename = os.fspath(filename) -- path, ok = _joinrealpath(filename[:0], filename, {}) -+ path, ok = _joinrealpath(filename[:0], filename, strict, {}) - return abspath(path) - - # Join two paths, normalizing and eliminating any symbolic links - # encountered in the second path. --def _joinrealpath(path, rest, seen): -+def _joinrealpath(path, rest, strict, seen): - if isinstance(path, bytes): - sep = b'/' - curdir = b'.' -@@ -423,7 +423,15 @@ def _joinrealpath(path, rest, seen): - path = pardir - continue - newpath = join(path, name) -- if not islink(newpath): -+ try: -+ st = os.lstat(newpath) -+ except OSError: -+ if strict: -+ raise -+ is_link = False -+ else: -+ is_link = stat.S_ISLNK(st.st_mode) -+ if not is_link: - path = newpath - continue - # Resolve the symbolic link -@@ -434,10 +442,14 @@ def _joinrealpath(path, rest, seen): - # use cached value - continue - # The symlink is not resolved, so we must have a symlink loop. -- # Return already resolved part + rest of the path unchanged. -- return join(newpath, rest), False -+ if strict: -+ # Raise OSError(errno.ELOOP) -+ os.stat(newpath) -+ else: -+ # Return already resolved part + rest of the path unchanged. -+ return join(newpath, rest), False - seen[newpath] = None # not resolved symlink -- path, ok = _joinrealpath(path, os.readlink(newpath), seen) -+ path, ok = _joinrealpath(path, os.readlink(newpath), strict, seen) - if not ok: - return join(path, rest), False - seen[newpath] = path # resolved symlink -diff --git a/Lib/test/test_ntpath.py b/Lib/test/test_ntpath.py -index 6f881f1..2d3fa10 100644 ---- a/Lib/test/test_ntpath.py -+++ b/Lib/test/test_ntpath.py -@@ -267,6 +267,17 @@ class TestNtpath(NtpathTestCase): - self.assertPathEqual(ntpath.realpath(os.fsencode(ABSTFN + "1")), - os.fsencode(ABSTFN)) - -+ @support.skip_unless_symlink -+ @unittest.skipUnless(HAVE_GETFINALPATHNAME, 'need _getfinalpathname') -+ def test_realpath_strict(self): -+ # Bug #43757: raise FileNotFoundError in strict mode if we encounter -+ # a path that does not exist. -+ ABSTFN = ntpath.abspath(support.TESTFN) -+ os.symlink(ABSTFN + "1", ABSTFN) -+ self.addCleanup(support.unlink, ABSTFN) -+ self.assertRaises(FileNotFoundError, ntpath.realpath, ABSTFN, strict=True) -+ self.assertRaises(FileNotFoundError, ntpath.realpath, ABSTFN + "2", strict=True) -+ - @support.skip_unless_symlink - @unittest.skipUnless(HAVE_GETFINALPATHNAME, 'need _getfinalpathname') - def test_realpath_relative(self): -@@ -338,8 +349,9 @@ class TestNtpath(NtpathTestCase): - @support.skip_unless_symlink - @unittest.skipUnless(HAVE_GETFINALPATHNAME, 'need _getfinalpathname') - def test_realpath_symlink_loops(self): -- # Symlink loops are non-deterministic as to which path is returned, but -- # it will always be the fully resolved path of one member of the cycle -+ # Symlink loops in non-strict mode are non-deterministic as to which -+ # path is returned, but it will always be the fully resolved path of -+ # one member of the cycle - ABSTFN = ntpath.abspath(support.TESTFN) - self.addCleanup(support.unlink, ABSTFN) - self.addCleanup(support.unlink, ABSTFN + "1") -@@ -381,6 +393,50 @@ class TestNtpath(NtpathTestCase): - # Test using relative path as well. - self.assertPathEqual(ntpath.realpath(ntpath.basename(ABSTFN)), ABSTFN) - -+ @support.skip_unless_symlink -+ @unittest.skipUnless(HAVE_GETFINALPATHNAME, 'need _getfinalpathname') -+ def test_realpath_symlink_loops_strict(self): -+ # Symlink loops raise OSError in strict mode -+ ABSTFN = ntpath.abspath(support.TESTFN) -+ self.addCleanup(support.unlink, ABSTFN) -+ self.addCleanup(support.unlink, ABSTFN + "1") -+ self.addCleanup(support.unlink, ABSTFN + "2") -+ self.addCleanup(support.unlink, ABSTFN + "y") -+ self.addCleanup(support.unlink, ABSTFN + "c") -+ self.addCleanup(support.unlink, ABSTFN + "a") -+ -+ os.symlink(ABSTFN, ABSTFN) -+ self.assertRaises(OSError, ntpath.realpath, ABSTFN, strict=True) -+ -+ os.symlink(ABSTFN + "1", ABSTFN + "2") -+ os.symlink(ABSTFN + "2", ABSTFN + "1") -+ self.assertRaises(OSError, ntpath.realpath, ABSTFN + "1", strict=True) -+ self.assertRaises(OSError, ntpath.realpath, ABSTFN + "2", strict=True) -+ self.assertRaises(OSError, ntpath.realpath, ABSTFN + "1\\x", strict=True) -+ # Windows eliminates '..' components before resolving links, so the -+ # following call is not expected to raise. -+ self.assertPathEqual(ntpath.realpath(ABSTFN + "1\\..", strict=True), -+ ntpath.dirname(ABSTFN)) -+ self.assertRaises(OSError, ntpath.realpath, ABSTFN + "1\\..\\x", strict=True) -+ os.symlink(ABSTFN + "x", ABSTFN + "y") -+ self.assertRaises(OSError, ntpath.realpath, ABSTFN + "1\\..\\" -+ + ntpath.basename(ABSTFN) + "y", -+ strict=True) -+ self.assertRaises(OSError, ntpath.realpath, -+ ABSTFN + "1\\..\\" + ntpath.basename(ABSTFN) + "1", -+ strict=True) -+ -+ os.symlink(ntpath.basename(ABSTFN) + "a\\b", ABSTFN + "a") -+ self.assertRaises(OSError, ntpath.realpath, ABSTFN + "a", strict=True) -+ -+ os.symlink("..\\" + ntpath.basename(ntpath.dirname(ABSTFN)) -+ + "\\" + ntpath.basename(ABSTFN) + "c", ABSTFN + "c") -+ self.assertRaises(OSError, ntpath.realpath, ABSTFN + "c", strict=True) -+ -+ # Test using relative path as well. -+ self.assertRaises(OSError, ntpath.realpath, ntpath.basename(ABSTFN), -+ strict=True) -+ - @support.skip_unless_symlink - @unittest.skipUnless(HAVE_GETFINALPATHNAME, 'need _getfinalpathname') - def test_realpath_symlink_prefix(self): -diff --git a/Lib/test/test_posixpath.py b/Lib/test/test_posixpath.py -index 18819a5..aee3cb4 100644 ---- a/Lib/test/test_posixpath.py -+++ b/Lib/test/test_posixpath.py -@@ -350,6 +350,19 @@ class PosixPathTest(unittest.TestCase): - finally: - support.unlink(ABSTFN) - -+ @unittest.skipUnless(hasattr(os, "symlink"), -+ "Missing symlink implementation") -+ @skip_if_ABSTFN_contains_backslash -+ def test_realpath_strict(self): -+ # Bug #43757: raise FileNotFoundError in strict mode if we encounter -+ # a path that does not exist. -+ try: -+ os.symlink(ABSTFN+"1", ABSTFN) -+ self.assertRaises(FileNotFoundError, realpath, ABSTFN, strict=True) -+ self.assertRaises(FileNotFoundError, realpath, ABSTFN + "2", strict=True) -+ finally: -+ support.unlink(ABSTFN) -+ - @unittest.skipUnless(hasattr(os, "symlink"), - "Missing symlink implementation") - @skip_if_ABSTFN_contains_backslash -@@ -365,7 +378,7 @@ class PosixPathTest(unittest.TestCase): - @skip_if_ABSTFN_contains_backslash - def test_realpath_symlink_loops(self): - # Bug #930024, return the path unchanged if we get into an infinite -- # symlink loop. -+ # symlink loop in non-strict mode (default). - try: - os.symlink(ABSTFN, ABSTFN) - self.assertEqual(realpath(ABSTFN), ABSTFN) -@@ -402,6 +415,48 @@ class PosixPathTest(unittest.TestCase): - support.unlink(ABSTFN+"c") - support.unlink(ABSTFN+"a") - -+ @unittest.skipUnless(hasattr(os, "symlink"), -+ "Missing symlink implementation") -+ @skip_if_ABSTFN_contains_backslash -+ def test_realpath_symlink_loops_strict(self): -+ # Bug #43757, raise OSError if we get into an infinite symlink loop in -+ # strict mode. -+ try: -+ os.symlink(ABSTFN, ABSTFN) -+ self.assertRaises(OSError, realpath, ABSTFN, strict=True) -+ -+ os.symlink(ABSTFN+"1", ABSTFN+"2") -+ os.symlink(ABSTFN+"2", ABSTFN+"1") -+ self.assertRaises(OSError, realpath, ABSTFN+"1", strict=True) -+ self.assertRaises(OSError, realpath, ABSTFN+"2", strict=True) -+ -+ self.assertRaises(OSError, realpath, ABSTFN+"1/x", strict=True) -+ self.assertRaises(OSError, realpath, ABSTFN+"1/..", strict=True) -+ self.assertRaises(OSError, realpath, ABSTFN+"1/../x", strict=True) -+ os.symlink(ABSTFN+"x", ABSTFN+"y") -+ self.assertRaises(OSError, realpath, -+ ABSTFN+"1/../" + basename(ABSTFN) + "y", strict=True) -+ self.assertRaises(OSError, realpath, -+ ABSTFN+"1/../" + basename(ABSTFN) + "1", strict=True) -+ -+ os.symlink(basename(ABSTFN) + "a/b", ABSTFN+"a") -+ self.assertRaises(OSError, realpath, ABSTFN+"a", strict=True) -+ -+ os.symlink("../" + basename(dirname(ABSTFN)) + "/" + -+ basename(ABSTFN) + "c", ABSTFN+"c") -+ self.assertRaises(OSError, realpath, ABSTFN+"c", strict=True) -+ -+ # Test using relative path as well. -+ with support.change_cwd(dirname(ABSTFN)): -+ self.assertRaises(OSError, realpath, basename(ABSTFN), strict=True) -+ finally: -+ support.unlink(ABSTFN) -+ support.unlink(ABSTFN+"1") -+ support.unlink(ABSTFN+"2") -+ support.unlink(ABSTFN+"y") -+ support.unlink(ABSTFN+"c") -+ support.unlink(ABSTFN+"a") -+ - @unittest.skipUnless(hasattr(os, "symlink"), - "Missing symlink implementation") - @skip_if_ABSTFN_contains_backslash -diff --git a/Misc/NEWS.d/next/Library/2021-04-08-22-11-27.bpo-25264.b33fa0.rst b/Misc/NEWS.d/next/Library/2021-04-08-22-11-27.bpo-25264.b33fa0.rst -new file mode 100644 -index 0000000..593846e ---- /dev/null -+++ b/Misc/NEWS.d/next/Library/2021-04-08-22-11-27.bpo-25264.b33fa0.rst -@@ -0,0 +1,3 @@ -+:func:`os.path.realpath` now accepts a *strict* keyword-only argument. -+When set to ``True``, :exc:`OSError` is raised if a path doesn't exist -+or a symlink loop is encountered. --- -2.49.0 - - -From 37c252c46f74628d68f596764fa34f82b3c17d05 Mon Sep 17 00:00:00 2001 -From: "T. Wouters" -Date: Tue, 3 Jun 2025 19:02:50 +0200 -Subject: [PATCH 2/2] gh-135034: Normalize link targets in tarfile, add - `os.path.realpath(strict='allow_missing')` (GH-135037) (GH-135084) -MIME-Version: 1.0 -Content-Type: text/plain; charset=UTF-8 -Content-Transfer-Encoding: 8bit - -Addresses CVEs 2024-12718, 2025-4138, 2025-4330, and 2025-4517. -(cherry picked from commit 3612d8f51741b11f36f8fb0494d79086bac9390a) - -Co-authored-by: Łukasz Langa -Co-authored-by: Petr Viktorin -Co-authored-by: Seth Michael Larson -Co-authored-by: Adam Turner <9087854+AA-Turner@users.noreply.github.com> -Co-authored-by: Serhiy Storchaka ---- - Doc/library/os.path.rst | 33 +- - Doc/library/tarfile.rst | 20 ++ - Doc/whatsnew/3.9.rst | 34 ++ - Lib/genericpath.py | 11 +- - Lib/ntpath.py | 35 +- - Lib/posixpath.py | 15 +- - Lib/tarfile.py | 161 +++++++-- - Lib/test/test_ntpath.py | 169 +++++++++- - Lib/test/test_posixpath.py | 282 +++++++++++++--- - Lib/test/test_tarfile.py | 314 ++++++++++++++++-- - ...-06-02-11-32-23.gh-issue-135034.RLGjbp.rst | 6 + - 11 files changed, 946 insertions(+), 134 deletions(-) - create mode 100644 Misc/NEWS.d/next/Security/2025-06-02-11-32-23.gh-issue-135034.RLGjbp.rst - -diff --git a/Doc/library/os.path.rst b/Doc/library/os.path.rst -index 19a5ae0..235a4de 100644 ---- a/Doc/library/os.path.rst -+++ b/Doc/library/os.path.rst -@@ -351,10 +351,26 @@ the :mod:`glob` module.) - links encountered in the path (if they are supported by the operating - system). - -- If a path doesn't exist or a symlink loop is encountered, and *strict* is -- ``True``, :exc:`OSError` is raised. If *strict* is ``False``, the path is -- resolved as far as possible and any remainder is appended without checking -- whether it exists. -+ By default, the path is evaluated up to the first component that does not -+ exist, is a symlink loop, or whose evaluation raises :exc:`OSError`. -+ All such components are appended unchanged to the existing part of the path. -+ -+ Some errors that are handled this way include "access denied", "not a -+ directory", or "bad argument to internal function". Thus, the -+ resulting path may be missing or inaccessible, may still contain -+ links or loops, and may traverse non-directories. -+ -+ This behavior can be modified by keyword arguments: -+ -+ If *strict* is ``True``, the first error encountered when evaluating the path is -+ re-raised. -+ In particular, :exc:`FileNotFoundError` is raised if *path* does not exist, -+ or another :exc:`OSError` if it is otherwise inaccessible. -+ -+ If *strict* is :py:data:`os.path.ALLOW_MISSING`, errors other than -+ :exc:`FileNotFoundError` are re-raised (as with ``strict=True``). -+ Thus, the returned path will not contain any symbolic links, but the named -+ file and some of its parent directories may be missing. - - .. note:: - This function emulates the operating system's procedure for making a path -@@ -373,6 +389,15 @@ the :mod:`glob` module.) - .. versionchanged:: 3.9.23 - The *strict* parameter was added. - -+ .. versionchanged:: next -+ The :py:data:`~os.path.ALLOW_MISSING` value for the *strict* parameter -+ was added. -+ -+.. data:: ALLOW_MISSING -+ -+ Special value used for the *strict* argument in :func:`realpath`. -+ -+ .. versionadded:: next - - .. function:: relpath(path, start=os.curdir) - -diff --git a/Doc/library/tarfile.rst b/Doc/library/tarfile.rst -index 1650885..d03f13d 100644 ---- a/Doc/library/tarfile.rst -+++ b/Doc/library/tarfile.rst -@@ -237,6 +237,15 @@ The :mod:`tarfile` module defines the following exceptions: - Raised to refuse extracting a symbolic link pointing outside the destination - directory. - -+.. exception:: LinkFallbackError -+ -+ Raised to refuse emulating a link (hard or symbolic) by extracting another -+ archive member, when that member would be rejected by the filter location. -+ The exception that was raised to reject the replacement member is available -+ as :attr:`!BaseException.__context__`. -+ -+ .. versionadded:: next -+ - - The following constants are available at the module level: - -@@ -954,6 +963,12 @@ reused in custom filters: - Implements the ``'data'`` filter. - In addition to what ``tar_filter`` does: - -+ - Normalize link targets (:attr:`TarInfo.linkname`) using -+ :func:`os.path.normpath`. -+ Note that this removes internal ``..`` components, which may change the -+ meaning of the link if the path in :attr:`!TarInfo.linkname` traverses -+ symbolic links. -+ - - :ref:`Refuse ` to extract links (hard or soft) - that link to absolute paths, or ones that link outside the destination. - -@@ -982,6 +997,10 @@ reused in custom filters: - - Return the modified ``TarInfo`` member. - -+ .. versionchanged:: next -+ -+ Link targets are now normalized. -+ - - .. _tarfile-extraction-refuse: - -@@ -1008,6 +1027,7 @@ Here is an incomplete list of things to consider: - * Extract to a :func:`new temporary directory ` - to prevent e.g. exploiting pre-existing links, and to make it easier to - clean up after a failed extraction. -+* Disallow symbolic links if you do not need the functionality. - * When working with untrusted data, use external (e.g. OS-level) limits on - disk, memory and CPU usage. - * Check filenames against an allow-list of characters -diff --git a/Doc/whatsnew/3.9.rst b/Doc/whatsnew/3.9.rst -index f4c3876..8196f76 100644 ---- a/Doc/whatsnew/3.9.rst -+++ b/Doc/whatsnew/3.9.rst -@@ -1662,3 +1662,37 @@ email - check if the *strict* paramater is available. - (Contributed by Thomas Dwyer and Victor Stinner for :gh:`102988` to improve - the CVE-2023-27043 fix.) -+ -+ -+Notable changes in 3.9.23 -+========================= -+ -+os.path -+------- -+ -+* The *strict* parameter to :func:`os.path.realpath` accepts a new value, -+ :data:`os.path.ALLOW_MISSING`. -+ If used, errors other than :exc:`FileNotFoundError` will be re-raised; -+ the resulting path can be missing but it will be free of symlinks. -+ (Contributed by Petr Viktorin for CVE 2025-4517.) -+ -+tarfile -+------- -+ -+* :func:`~tarfile.data_filter` now normalizes symbolic link targets in order to -+ avoid path traversal attacks. -+ (Contributed by Petr Viktorin in :gh:`127987` and CVE 2025-4138.) -+* :func:`~tarfile.TarFile.extractall` now skips fixing up directory attributes -+ when a directory was removed or replaced by another kind of file. -+ (Contributed by Petr Viktorin in :gh:`127987` and CVE 2024-12718.) -+* :func:`~tarfile.TarFile.extract` and :func:`~tarfile.TarFile.extractall` -+ now (re-)apply the extraction filter when substituting a link (hard or -+ symbolic) with a copy of another archive member, and when fixing up -+ directory attributes. -+ The former raises a new exception, :exc:`~tarfile.LinkFallbackError`. -+ (Contributed by Petr Viktorin for CVE 2025-4330 and CVE 2024-12718.) -+* :func:`~tarfile.TarFile.extract` and :func:`~tarfile.TarFile.extractall` -+ no longer extract rejected members when -+ :func:`~tarfile.TarFile.errorlevel` is zero. -+ (Contributed by Matt Prodani and Petr Viktorin in :gh:`112887` -+ and CVE 2025-4435.) -diff --git a/Lib/genericpath.py b/Lib/genericpath.py -index ce36451..ad8d47b 100644 ---- a/Lib/genericpath.py -+++ b/Lib/genericpath.py -@@ -8,7 +8,7 @@ import stat - - __all__ = ['commonprefix', 'exists', 'getatime', 'getctime', 'getmtime', - 'getsize', 'isdir', 'isfile', 'samefile', 'sameopenfile', -- 'samestat'] -+ 'samestat', 'ALLOW_MISSING'] - - - # Does a path exist? -@@ -153,3 +153,12 @@ def _check_arg_types(funcname, *args): - f'os.PathLike object, not {s.__class__.__name__!r}') from None - if hasstr and hasbytes: - raise TypeError("Can't mix strings and bytes in path components") from None -+ -+# A singleton with a true boolean value. -+@object.__new__ -+class ALLOW_MISSING: -+ """Special value for use in realpath().""" -+ def __repr__(self): -+ return 'os.path.ALLOW_MISSING' -+ def __reduce__(self): -+ return self.__class__.__name__ -diff --git a/Lib/ntpath.py b/Lib/ntpath.py -index 92b46e2..2588ea3 100644 ---- a/Lib/ntpath.py -+++ b/Lib/ntpath.py -@@ -29,7 +29,8 @@ __all__ = ["normcase","isabs","join","splitdrive","split","splitext", - "ismount", "expanduser","expandvars","normpath","abspath", - "curdir","pardir","sep","pathsep","defpath","altsep", - "extsep","devnull","realpath","supports_unicode_filenames","relpath", -- "samefile", "sameopenfile", "samestat", "commonpath"] -+ "samefile", "sameopenfile", "samestat", "commonpath", -+ "ALLOW_MISSING"] - - def _get_bothseps(path): - if isinstance(path, bytes): -@@ -532,9 +533,10 @@ try: - from nt import _getfinalpathname, readlink as _nt_readlink - except ImportError: - # realpath is a no-op on systems without _getfinalpathname support. -- realpath = abspath -+ def realpath(path, *, strict=False): -+ return abspath(path) - else: -- def _readlink_deep(path): -+ def _readlink_deep(path, ignored_error=OSError): - # These error codes indicate that we should stop reading links and - # return the path we currently have. - # 1: ERROR_INVALID_FUNCTION -@@ -567,7 +569,7 @@ else: - path = old_path - break - path = normpath(join(dirname(old_path), path)) -- except OSError as ex: -+ except ignored_error as ex: - if ex.winerror in allowed_winerror: - break - raise -@@ -576,7 +578,7 @@ else: - break - return path - -- def _getfinalpathname_nonstrict(path): -+ def _getfinalpathname_nonstrict(path, ignored_error=OSError): - # These error codes indicate that we should stop resolving the path - # and return the value we currently have. - # 1: ERROR_INVALID_FUNCTION -@@ -600,17 +602,18 @@ else: - try: - path = _getfinalpathname(path) - return join(path, tail) if tail else path -- except OSError as ex: -+ except ignored_error as ex: - if ex.winerror not in allowed_winerror: - raise - try: - # The OS could not resolve this path fully, so we attempt - # to follow the link ourselves. If we succeed, join the tail - # and return. -- new_path = _readlink_deep(path) -+ new_path = _readlink_deep(path, -+ ignored_error=ignored_error) - if new_path != path: - return join(new_path, tail) if tail else new_path -- except OSError: -+ except ignored_error: - # If we fail to readlink(), let's keep traversing - pass - path, name = split(path) -@@ -641,16 +644,24 @@ else: - if normcase(path) == normcase(devnull): - return '\\\\.\\NUL' - had_prefix = path.startswith(prefix) -+ -+ if strict is ALLOW_MISSING: -+ ignored_error = FileNotFoundError -+ strict = True -+ elif strict: -+ ignored_error = () -+ else: -+ ignored_error = OSError -+ - if not had_prefix and not isabs(path): - path = join(cwd, path) - try: - path = _getfinalpathname(path) - initial_winerror = 0 -- except OSError as ex: -- if strict: -- raise -+ except ignored_error as ex: - initial_winerror = ex.winerror -- path = _getfinalpathname_nonstrict(path) -+ path = _getfinalpathname_nonstrict(path, -+ ignored_error=ignored_error) - # The path returned by _getfinalpathname will always start with \\?\ - - # strip off that prefix unless it was already provided on the original - # path. -diff --git a/Lib/posixpath.py b/Lib/posixpath.py -index eb80fb9..de2b90c 100644 ---- a/Lib/posixpath.py -+++ b/Lib/posixpath.py -@@ -35,7 +35,7 @@ __all__ = ["normcase","isabs","join","splitdrive","split","splitext", - "samefile","sameopenfile","samestat", - "curdir","pardir","sep","pathsep","defpath","altsep","extsep", - "devnull","realpath","supports_unicode_filenames","relpath", -- "commonpath"] -+ "commonpath", "ALLOW_MISSING"] - - - def _get_sep(path): -@@ -403,6 +403,15 @@ def _joinrealpath(path, rest, strict, seen): - sep = '/' - curdir = '.' - pardir = '..' -+ getcwd = os.getcwd -+ if strict is ALLOW_MISSING: -+ ignored_error = FileNotFoundError -+ elif strict: -+ ignored_error = () -+ else: -+ ignored_error = OSError -+ -+ maxlinks = None - - if isabs(rest): - rest = rest[1:] -@@ -425,9 +434,7 @@ def _joinrealpath(path, rest, strict, seen): - newpath = join(path, name) - try: - st = os.lstat(newpath) -- except OSError: -- if strict: -- raise -+ except ignored_error: - is_link = False - else: - is_link = stat.S_ISLNK(st.st_mode) -diff --git a/Lib/tarfile.py b/Lib/tarfile.py -index 9388592..21ffd83 100755 ---- a/Lib/tarfile.py -+++ b/Lib/tarfile.py -@@ -756,10 +756,22 @@ class LinkOutsideDestinationError(FilterError): - super().__init__(f'{tarinfo.name!r} would link to {path!r}, ' - + 'which is outside the destination') - -+class LinkFallbackError(FilterError): -+ def __init__(self, tarinfo, path): -+ self.tarinfo = tarinfo -+ self._path = path -+ super().__init__(f'link {tarinfo.name!r} would be extracted as a ' -+ + f'copy of {path!r}, which was rejected') -+ -+# Errors caused by filters -- both "fatal" and "non-fatal" -- that -+# we consider to be issues with the argument, rather than a bug in the -+# filter function -+_FILTER_ERRORS = (FilterError, OSError, ExtractError) -+ - def _get_filtered_attrs(member, dest_path, for_data=True): - new_attrs = {} - name = member.name -- dest_path = os.path.realpath(dest_path) -+ dest_path = os.path.realpath(dest_path, strict=os.path.ALLOW_MISSING) - # Strip leading / (tar's directory separator) from filenames. - # Include os.sep (target OS directory separator) as well. - if name.startswith(('/', os.sep)): -@@ -769,7 +781,8 @@ def _get_filtered_attrs(member, dest_path, for_data=True): - # For example, 'C:/foo' on Windows. - raise AbsolutePathError(member) - # Ensure we stay in the destination -- target_path = os.path.realpath(os.path.join(dest_path, name)) -+ target_path = os.path.realpath(os.path.join(dest_path, name), -+ strict=os.path.ALLOW_MISSING) - if os.path.commonpath([target_path, dest_path]) != dest_path: - raise OutsideDestinationError(member, target_path) - # Limit permissions (no high bits, and go-w) -@@ -807,6 +820,9 @@ def _get_filtered_attrs(member, dest_path, for_data=True): - if member.islnk() or member.issym(): - if os.path.isabs(member.linkname): - raise AbsoluteLinkError(member) -+ normalized = os.path.normpath(member.linkname) -+ if normalized != member.linkname: -+ new_attrs['linkname'] = normalized - if member.issym(): - target_path = os.path.join(dest_path, - os.path.dirname(name), -@@ -814,7 +830,8 @@ def _get_filtered_attrs(member, dest_path, for_data=True): - else: - target_path = os.path.join(dest_path, - member.linkname) -- target_path = os.path.realpath(target_path) -+ target_path = os.path.realpath(target_path, -+ strict=os.path.ALLOW_MISSING) - if os.path.commonpath([target_path, dest_path]) != dest_path: - raise LinkOutsideDestinationError(member, target_path) - return new_attrs -@@ -2310,30 +2327,58 @@ class TarFile(object): - members = self - - for member in members: -- tarinfo = self._get_extract_tarinfo(member, filter_function, path) -+ tarinfo, unfiltered = self._get_extract_tarinfo( -+ member, filter_function, path) - if tarinfo is None: - continue - if tarinfo.isdir(): - # For directories, delay setting attributes until later, - # since permissions can interfere with extraction and - # extracting contents can reset mtime. -- directories.append(tarinfo) -+ directories.append(unfiltered) - self._extract_one(tarinfo, path, set_attrs=not tarinfo.isdir(), -- numeric_owner=numeric_owner) -+ numeric_owner=numeric_owner, -+ filter_function=filter_function) - - # Reverse sort directories. - directories.sort(key=lambda a: a.name, reverse=True) - -+ - # Set correct owner, mtime and filemode on directories. -- for tarinfo in directories: -- dirpath = os.path.join(path, tarinfo.name) -+ for unfiltered in directories: - try: -+ # Need to re-apply any filter, to take the *current* filesystem -+ # state into account. -+ try: -+ tarinfo = filter_function(unfiltered, path) -+ except _FILTER_ERRORS as exc: -+ self._log_no_directory_fixup(unfiltered, repr(exc)) -+ continue -+ if tarinfo is None: -+ self._log_no_directory_fixup(unfiltered, -+ 'excluded by filter') -+ continue -+ dirpath = os.path.join(path, tarinfo.name) -+ try: -+ lstat = os.lstat(dirpath) -+ except FileNotFoundError: -+ self._log_no_directory_fixup(tarinfo, 'missing') -+ continue -+ if not stat.S_ISDIR(lstat.st_mode): -+ # This is no longer a directory; presumably a later -+ # member overwrote the entry. -+ self._log_no_directory_fixup(tarinfo, 'not a directory') -+ continue - self.chown(tarinfo, dirpath, numeric_owner=numeric_owner) - self.utime(tarinfo, dirpath) - self.chmod(tarinfo, dirpath) - except ExtractError as e: - self._handle_nonfatal_error(e) - -+ def _log_no_directory_fixup(self, member, reason): -+ self._dbg(2, "tarfile: Not fixing up directory %r (%s)" % -+ (member.name, reason)) -+ - def extract(self, member, path="", set_attrs=True, *, numeric_owner=False, - filter=None): - """Extract a member from the archive to the current working directory, -@@ -2349,41 +2394,56 @@ class TarFile(object): - String names of common filters are accepted. - """ - filter_function = self._get_filter_function(filter) -- tarinfo = self._get_extract_tarinfo(member, filter_function, path) -+ tarinfo, unfiltered = self._get_extract_tarinfo( -+ member, filter_function, path) - if tarinfo is not None: - self._extract_one(tarinfo, path, set_attrs, numeric_owner) - - def _get_extract_tarinfo(self, member, filter_function, path): -- """Get filtered TarInfo (or None) from member, which might be a str""" -+ """Get (filtered, unfiltered) TarInfos from *member* -+ -+ *member* might be a string. -+ -+ Return (None, None) if not found. -+ """ -+ - if isinstance(member, str): -- tarinfo = self.getmember(member) -+ unfiltered = self.getmember(member) - else: -- tarinfo = member -+ unfiltered = member - -- unfiltered = tarinfo -+ filtered = None - try: -- tarinfo = filter_function(tarinfo, path) -+ filtered = filter_function(unfiltered, path) - except (OSError, FilterError) as e: - self._handle_fatal_error(e) - except ExtractError as e: - self._handle_nonfatal_error(e) -- if tarinfo is None: -+ if filtered is None: - self._dbg(2, "tarfile: Excluded %r" % unfiltered.name) -- return None -+ return None, None -+ - # Prepare the link target for makelink(). -- if tarinfo.islnk(): -- tarinfo = copy.copy(tarinfo) -- tarinfo._link_target = os.path.join(path, tarinfo.linkname) -- return tarinfo -+ if filtered.islnk(): -+ filtered = copy.copy(filtered) -+ filtered._link_target = os.path.join(path, filtered.linkname) -+ return filtered, unfiltered - -- def _extract_one(self, tarinfo, path, set_attrs, numeric_owner): -- """Extract from filtered tarinfo to disk""" -+ def _extract_one(self, tarinfo, path, set_attrs, numeric_owner, -+ filter_function=None): -+ """Extract from filtered tarinfo to disk. -+ -+ filter_function is only used when extracting a *different* -+ member (e.g. as fallback to creating a symlink) -+ """ - self._check("r") - - try: - self._extract_member(tarinfo, os.path.join(path, tarinfo.name), - set_attrs=set_attrs, -- numeric_owner=numeric_owner) -+ numeric_owner=numeric_owner, -+ filter_function=filter_function, -+ extraction_root=path) - except OSError as e: - self._handle_fatal_error(e) - except ExtractError as e: -@@ -2441,9 +2501,13 @@ class TarFile(object): - return None - - def _extract_member(self, tarinfo, targetpath, set_attrs=True, -- numeric_owner=False): -- """Extract the TarInfo object tarinfo to a physical -+ numeric_owner=False, *, filter_function=None, -+ extraction_root=None): -+ """Extract the filtered TarInfo object tarinfo to a physical - file called targetpath. -+ -+ filter_function is only used when extracting a *different* -+ member (e.g. as fallback to creating a symlink) - """ - # Fetch the TarInfo object for the given name - # and build the destination pathname, replacing -@@ -2472,7 +2536,10 @@ class TarFile(object): - elif tarinfo.ischr() or tarinfo.isblk(): - self.makedev(tarinfo, targetpath) - elif tarinfo.islnk() or tarinfo.issym(): -- self.makelink(tarinfo, targetpath) -+ self.makelink_with_filter( -+ tarinfo, targetpath, -+ filter_function=filter_function, -+ extraction_root=extraction_root) - elif tarinfo.type not in SUPPORTED_TYPES: - self.makeunknown(tarinfo, targetpath) - else: -@@ -2554,10 +2621,18 @@ class TarFile(object): - os.makedev(tarinfo.devmajor, tarinfo.devminor)) - - def makelink(self, tarinfo, targetpath): -+ return self.makelink_with_filter(tarinfo, targetpath, None, None) -+ -+ def makelink_with_filter(self, tarinfo, targetpath, -+ filter_function, extraction_root): - """Make a (symbolic) link called targetpath. If it cannot be created - (platform limitation), we try to make a copy of the referenced file - instead of a link. -+ -+ filter_function is only used when extracting a *different* -+ member (e.g. as fallback to creating a link). - """ -+ keyerror_to_extracterror = False - try: - # For systems that support symbolic and hard links. - if tarinfo.issym(): -@@ -2565,18 +2640,38 @@ class TarFile(object): - # Avoid FileExistsError on following os.symlink. - os.unlink(targetpath) - os.symlink(tarinfo.linkname, targetpath) -+ return - else: - if os.path.exists(tarinfo._link_target): - os.link(tarinfo._link_target, targetpath) -- else: -- self._extract_member(self._find_link_target(tarinfo), -- targetpath) -+ return - except symlink_exception: -+ keyerror_to_extracterror = True -+ -+ try: -+ unfiltered = self._find_link_target(tarinfo) -+ except KeyError: -+ if keyerror_to_extracterror: -+ raise ExtractError( -+ "unable to resolve link inside archive") -+ else: -+ raise -+ -+ if filter_function is None: -+ filtered = unfiltered -+ else: -+ if extraction_root is None: -+ raise ExtractError( -+ "makelink_with_filter: if filter_function is not None, " -+ + "extraction_root must also not be None") - try: -- self._extract_member(self._find_link_target(tarinfo), -- targetpath) -- except KeyError: -- raise ExtractError("unable to resolve link inside archive") -+ filtered = filter_function(unfiltered, extraction_root) -+ except _FILTER_ERRORS as cause: -+ raise LinkFallbackError(tarinfo, unfiltered.name) from cause -+ if filtered is not None: -+ self._extract_member(filtered, targetpath, -+ filter_function=filter_function, -+ extraction_root=extraction_root) - - def chown(self, tarinfo, targetpath, numeric_owner): - """Set owner of targetpath according to tarinfo. If numeric_owner -diff --git a/Lib/test/test_ntpath.py b/Lib/test/test_ntpath.py -index 2d3fa10..8f07d18 100644 ---- a/Lib/test/test_ntpath.py -+++ b/Lib/test/test_ntpath.py -@@ -1,8 +1,10 @@ - import ntpath - import os -+import subprocess - import sys - import unittest - import warnings -+from ntpath import ALLOW_MISSING - from test.support import TestFailed, FakePath - from test import support, test_genericpath - from tempfile import TemporaryFile -@@ -72,6 +74,27 @@ def tester(fn, wantResult): - %(str(fn), str(wantResult), repr(gotResult))) - - -+def _parameterize(*parameters): -+ """Simplistic decorator to parametrize a test -+ -+ Runs the decorated test multiple times in subTest, with a value from -+ 'parameters' passed as an extra positional argument. -+ Calls doCleanups() after each run. -+ -+ Not for general use. Intended to avoid indenting for easier backports. -+ -+ See https://discuss.python.org/t/91827 for discussing generalizations. -+ """ -+ def _parametrize_decorator(func): -+ def _parameterized(self, *args, **kwargs): -+ for parameter in parameters: -+ with self.subTest(parameter): -+ func(self, *args, parameter, **kwargs) -+ self.doCleanups() -+ return _parameterized -+ return _parametrize_decorator -+ -+ - class NtpathTestCase(unittest.TestCase): - def assertPathEqual(self, path1, path2): - if path1 == path2 or _norm(path1) == _norm(path2): -@@ -242,6 +265,27 @@ class TestNtpath(NtpathTestCase): - tester("ntpath.realpath('.\\.')", expected) - tester("ntpath.realpath('\\'.join(['.'] * 100))", expected) - -+ def test_realpath_curdir_strict(self): -+ expected = ntpath.normpath(os.getcwd()) -+ tester("ntpath.realpath('.', strict=True)", expected) -+ tester("ntpath.realpath('./.', strict=True)", expected) -+ tester("ntpath.realpath('/'.join(['.'] * 100), strict=True)", expected) -+ tester("ntpath.realpath('.\\.', strict=True)", expected) -+ tester("ntpath.realpath('\\'.join(['.'] * 100), strict=True)", expected) -+ -+ def test_realpath_curdir_missing_ok(self): -+ expected = ntpath.normpath(os.getcwd()) -+ tester("ntpath.realpath('.', strict=ALLOW_MISSING)", -+ expected) -+ tester("ntpath.realpath('./.', strict=ALLOW_MISSING)", -+ expected) -+ tester("ntpath.realpath('/'.join(['.'] * 100), strict=ALLOW_MISSING)", -+ expected) -+ tester("ntpath.realpath('.\\.', strict=ALLOW_MISSING)", -+ expected) -+ tester("ntpath.realpath('\\'.join(['.'] * 100), strict=ALLOW_MISSING)", -+ expected) -+ - def test_realpath_pardir(self): - expected = ntpath.normpath(os.getcwd()) - tester("ntpath.realpath('..')", ntpath.dirname(expected)) -@@ -254,17 +298,43 @@ class TestNtpath(NtpathTestCase): - tester("ntpath.realpath('\\'.join(['..'] * 50))", - ntpath.splitdrive(expected)[0] + '\\') - -+ def test_realpath_pardir_strict(self): -+ expected = ntpath.normpath(os.getcwd()) -+ tester("ntpath.realpath('..', strict=True)", ntpath.dirname(expected)) -+ tester("ntpath.realpath('../..', strict=True)", -+ ntpath.dirname(ntpath.dirname(expected))) -+ tester("ntpath.realpath('/'.join(['..'] * 50), strict=True)", -+ ntpath.splitdrive(expected)[0] + '\\') -+ tester("ntpath.realpath('..\\..', strict=True)", -+ ntpath.dirname(ntpath.dirname(expected))) -+ tester("ntpath.realpath('\\'.join(['..'] * 50), strict=True)", -+ ntpath.splitdrive(expected)[0] + '\\') -+ -+ def test_realpath_pardir_missing_ok(self): -+ expected = ntpath.normpath(os.getcwd()) -+ tester("ntpath.realpath('..', strict=ALLOW_MISSING)", -+ ntpath.dirname(expected)) -+ tester("ntpath.realpath('../..', strict=ALLOW_MISSING)", -+ ntpath.dirname(ntpath.dirname(expected))) -+ tester("ntpath.realpath('/'.join(['..'] * 50), strict=ALLOW_MISSING)", -+ ntpath.splitdrive(expected)[0] + '\\') -+ tester("ntpath.realpath('..\\..', strict=ALLOW_MISSING)", -+ ntpath.dirname(ntpath.dirname(expected))) -+ tester("ntpath.realpath('\\'.join(['..'] * 50), strict=ALLOW_MISSING)", -+ ntpath.splitdrive(expected)[0] + '\\') -+ - @support.skip_unless_symlink - @unittest.skipUnless(HAVE_GETFINALPATHNAME, 'need _getfinalpathname') -- def test_realpath_basic(self): -+ @_parameterize({}, {'strict': True}, {'strict': ALLOW_MISSING}) -+ def test_realpath_basic(self, kwargs): - ABSTFN = ntpath.abspath(support.TESTFN) - open(ABSTFN, "wb").close() - self.addCleanup(support.unlink, ABSTFN) - self.addCleanup(support.unlink, ABSTFN + "1") - - os.symlink(ABSTFN, ABSTFN + "1") -- self.assertPathEqual(ntpath.realpath(ABSTFN + "1"), ABSTFN) -- self.assertPathEqual(ntpath.realpath(os.fsencode(ABSTFN + "1")), -+ self.assertPathEqual(ntpath.realpath(ABSTFN + "1", **kwargs), ABSTFN) -+ self.assertPathEqual(ntpath.realpath(os.fsencode(ABSTFN + "1"), **kwargs), - os.fsencode(ABSTFN)) - - @support.skip_unless_symlink -@@ -280,14 +350,15 @@ class TestNtpath(NtpathTestCase): - - @support.skip_unless_symlink - @unittest.skipUnless(HAVE_GETFINALPATHNAME, 'need _getfinalpathname') -- def test_realpath_relative(self): -+ @_parameterize({}, {'strict': True}, {'strict': ALLOW_MISSING}) -+ def test_realpath_relative(self, kwargs): - ABSTFN = ntpath.abspath(support.TESTFN) - open(ABSTFN, "wb").close() - self.addCleanup(support.unlink, ABSTFN) - self.addCleanup(support.unlink, ABSTFN + "1") - - os.symlink(ABSTFN, ntpath.relpath(ABSTFN + "1")) -- self.assertPathEqual(ntpath.realpath(ABSTFN + "1"), ABSTFN) -+ self.assertPathEqual(ntpath.realpath(ABSTFN + "1", **kwargs), ABSTFN) - - @support.skip_unless_symlink - @unittest.skipUnless(HAVE_GETFINALPATHNAME, 'need _getfinalpathname') -@@ -439,7 +510,62 @@ class TestNtpath(NtpathTestCase): - - @support.skip_unless_symlink - @unittest.skipUnless(HAVE_GETFINALPATHNAME, 'need _getfinalpathname') -- def test_realpath_symlink_prefix(self): -+ def test_realpath_symlink_loops_raise(self): -+ # Symlink loops raise OSError in ALLOW_MISSING mode -+ ABSTFN = ntpath.abspath(support.TESTFN) -+ self.addCleanup(support.unlink, ABSTFN) -+ self.addCleanup(support.unlink, ABSTFN + "1") -+ self.addCleanup(support.unlink, ABSTFN + "2") -+ self.addCleanup(support.unlink, ABSTFN + "y") -+ self.addCleanup(support.unlink, ABSTFN + "c") -+ self.addCleanup(support.unlink, ABSTFN + "a") -+ self.addCleanup(support.unlink, ABSTFN + "x") -+ -+ os.symlink(ABSTFN, ABSTFN) -+ self.assertRaises(OSError, ntpath.realpath, ABSTFN, strict=ALLOW_MISSING) -+ -+ os.symlink(ABSTFN + "1", ABSTFN + "2") -+ os.symlink(ABSTFN + "2", ABSTFN + "1") -+ self.assertRaises(OSError, ntpath.realpath, ABSTFN + "1", -+ strict=ALLOW_MISSING) -+ self.assertRaises(OSError, ntpath.realpath, ABSTFN + "2", -+ strict=ALLOW_MISSING) -+ self.assertRaises(OSError, ntpath.realpath, ABSTFN + "1\\x", -+ strict=ALLOW_MISSING) -+ -+ # Windows eliminates '..' components before resolving links; -+ # realpath is not expected to raise if this removes the loop. -+ self.assertPathEqual(ntpath.realpath(ABSTFN + "1\\.."), -+ ntpath.dirname(ABSTFN)) -+ self.assertPathEqual(ntpath.realpath(ABSTFN + "1\\..\\x"), -+ ntpath.dirname(ABSTFN) + "\\x") -+ -+ os.symlink(ABSTFN + "x", ABSTFN + "y") -+ self.assertPathEqual(ntpath.realpath(ABSTFN + "1\\..\\" -+ + ntpath.basename(ABSTFN) + "y"), -+ ABSTFN + "x") -+ self.assertRaises( -+ OSError, ntpath.realpath, -+ ABSTFN + "1\\..\\" + ntpath.basename(ABSTFN) + "1", -+ strict=ALLOW_MISSING) -+ -+ os.symlink(ntpath.basename(ABSTFN) + "a\\b", ABSTFN + "a") -+ self.assertRaises(OSError, ntpath.realpath, ABSTFN + "a", -+ strict=ALLOW_MISSING) -+ -+ os.symlink("..\\" + ntpath.basename(ntpath.dirname(ABSTFN)) -+ + "\\" + ntpath.basename(ABSTFN) + "c", ABSTFN + "c") -+ self.assertRaises(OSError, ntpath.realpath, ABSTFN + "c", -+ strict=ALLOW_MISSING) -+ -+ # Test using relative path as well. -+ self.assertRaises(OSError, ntpath.realpath, ntpath.basename(ABSTFN), -+ strict=ALLOW_MISSING) -+ -+ @support.skip_unless_symlink -+ @unittest.skipUnless(HAVE_GETFINALPATHNAME, 'need _getfinalpathname') -+ @_parameterize({}, {'strict': True}, {'strict': ALLOW_MISSING}) -+ def test_realpath_symlink_prefix(self, kwargs): - ABSTFN = ntpath.abspath(support.TESTFN) - self.addCleanup(support.unlink, ABSTFN + "3") - self.addCleanup(support.unlink, "\\\\?\\" + ABSTFN + "3.") -@@ -454,9 +580,9 @@ class TestNtpath(NtpathTestCase): - f.write(b'1') - os.symlink("\\\\?\\" + ABSTFN + "3.", ABSTFN + "3.link") - -- self.assertPathEqual(ntpath.realpath(ABSTFN + "3link"), -+ self.assertPathEqual(ntpath.realpath(ABSTFN + "3link", **kwargs), - ABSTFN + "3") -- self.assertPathEqual(ntpath.realpath(ABSTFN + "3.link"), -+ self.assertPathEqual(ntpath.realpath(ABSTFN + "3.link", **kwargs), - "\\\\?\\" + ABSTFN + "3.") - - # Resolved paths should be usable to open target files -@@ -466,14 +592,17 @@ class TestNtpath(NtpathTestCase): - self.assertEqual(f.read(), b'1') - - # When the prefix is included, it is not stripped -- self.assertPathEqual(ntpath.realpath("\\\\?\\" + ABSTFN + "3link"), -+ self.assertPathEqual(ntpath.realpath("\\\\?\\" + ABSTFN + "3link", **kwargs), - "\\\\?\\" + ABSTFN + "3") -- self.assertPathEqual(ntpath.realpath("\\\\?\\" + ABSTFN + "3.link"), -+ self.assertPathEqual(ntpath.realpath("\\\\?\\" + ABSTFN + "3.link", **kwargs), - "\\\\?\\" + ABSTFN + "3.") - - @unittest.skipUnless(HAVE_GETFINALPATHNAME, 'need _getfinalpathname') - def test_realpath_nul(self): - tester("ntpath.realpath('NUL')", r'\\.\NUL') -+ tester("ntpath.realpath('NUL', strict=False)", r'\\.\NUL') -+ tester("ntpath.realpath('NUL', strict=True)", r'\\.\NUL') -+ tester("ntpath.realpath('NUL', strict=ALLOW_MISSING)", r'\\.\NUL') - - @unittest.skipUnless(HAVE_GETFINALPATHNAME, 'need _getfinalpathname') - @unittest.skipUnless(HAVE_GETSHORTPATHNAME, 'need _getshortpathname') -@@ -497,12 +626,20 @@ class TestNtpath(NtpathTestCase): - - self.assertPathEqual(test_file_long, ntpath.realpath(test_file_short)) - -- with support.change_cwd(test_dir_long): -- self.assertPathEqual(test_file_long, ntpath.realpath("file.txt")) -- with support.change_cwd(test_dir_long.lower()): -- self.assertPathEqual(test_file_long, ntpath.realpath("file.txt")) -- with support.change_cwd(test_dir_short): -- self.assertPathEqual(test_file_long, ntpath.realpath("file.txt")) -+ for kwargs in {}, {'strict': True}, {'strict': ALLOW_MISSING}: -+ with self.subTest(**kwargs): -+ with support.change_cwd(test_dir_long): -+ self.assertPathEqual( -+ test_file_long, -+ ntpath.realpath("file.txt", **kwargs)) -+ with support.change_cwd(test_dir_long.lower()): -+ self.assertPathEqual( -+ test_file_long, -+ ntpath.realpath("file.txt", **kwargs)) -+ with support.change_cwd(test_dir_short): -+ self.assertPathEqual( -+ test_file_long, -+ ntpath.realpath("file.txt", **kwargs)) - - def test_expandvars(self): - with support.EnvironmentVarGuard() as env: -diff --git a/Lib/test/test_posixpath.py b/Lib/test/test_posixpath.py -index aee3cb4..639cd2d 100644 ---- a/Lib/test/test_posixpath.py -+++ b/Lib/test/test_posixpath.py -@@ -1,7 +1,10 @@ - import os -+import sys - import posixpath - import unittest --from posixpath import realpath, abspath, dirname, basename -+from functools import partial -+from posixpath import realpath, abspath, dirname, basename, ALLOW_MISSING -+from test import support - from test import support, test_genericpath - from test.support import FakePath - from unittest import mock -@@ -33,6 +36,26 @@ def safe_rmdir(dirname): - except OSError: - pass - -+def _parameterize(*parameters): -+ """Simplistic decorator to parametrize a test -+ -+ Runs the decorated test multiple times in subTest, with a value from -+ 'parameters' passed as an extra positional argument. -+ Does *not* call doCleanups() after each run. -+ -+ Not for general use. Intended to avoid indenting for easier backports. -+ -+ See https://discuss.python.org/t/91827 for discussing generalizations. -+ """ -+ def _parametrize_decorator(func): -+ def _parameterized(self, *args, **kwargs): -+ for parameter in parameters: -+ with self.subTest(parameter): -+ func(self, *args, parameter, **kwargs) -+ return _parameterized -+ return _parametrize_decorator -+ -+ - class PosixPathTest(unittest.TestCase): - - def setUp(self): -@@ -320,33 +343,36 @@ class PosixPathTest(unittest.TestCase): - b"/foo/bar") - - @skip_if_ABSTFN_contains_backslash -- def test_realpath_curdir(self): -- self.assertEqual(realpath('.'), os.getcwd()) -- self.assertEqual(realpath('./.'), os.getcwd()) -- self.assertEqual(realpath('/'.join(['.'] * 100)), os.getcwd()) -+ @_parameterize({}, {'strict': True}, {'strict': ALLOW_MISSING}) -+ def test_realpath_curdir(self, kwargs): -+ self.assertEqual(realpath('.', **kwargs), os.getcwd()) -+ self.assertEqual(realpath('./.', **kwargs), os.getcwd()) -+ self.assertEqual(realpath('/'.join(['.'] * 100), **kwargs), os.getcwd()) - -- self.assertEqual(realpath(b'.'), os.getcwdb()) -- self.assertEqual(realpath(b'./.'), os.getcwdb()) -- self.assertEqual(realpath(b'/'.join([b'.'] * 100)), os.getcwdb()) -+ self.assertEqual(realpath(b'.', **kwargs), os.getcwdb()) -+ self.assertEqual(realpath(b'./.', **kwargs), os.getcwdb()) -+ self.assertEqual(realpath(b'/'.join([b'.'] * 100), **kwargs), os.getcwdb()) - - @skip_if_ABSTFN_contains_backslash -- def test_realpath_pardir(self): -- self.assertEqual(realpath('..'), dirname(os.getcwd())) -- self.assertEqual(realpath('../..'), dirname(dirname(os.getcwd()))) -- self.assertEqual(realpath('/'.join(['..'] * 100)), '/') -+ @_parameterize({}, {'strict': True}, {'strict': ALLOW_MISSING}) -+ def test_realpath_pardir(self, kwargs): -+ self.assertEqual(realpath('..', **kwargs), dirname(os.getcwd())) -+ self.assertEqual(realpath('../..', **kwargs), dirname(dirname(os.getcwd()))) -+ self.assertEqual(realpath('/'.join(['..'] * 100), **kwargs), '/') - -- self.assertEqual(realpath(b'..'), dirname(os.getcwdb())) -- self.assertEqual(realpath(b'../..'), dirname(dirname(os.getcwdb()))) -- self.assertEqual(realpath(b'/'.join([b'..'] * 100)), b'/') -+ self.assertEqual(realpath(b'..', **kwargs), dirname(os.getcwdb())) -+ self.assertEqual(realpath(b'../..', **kwargs), dirname(dirname(os.getcwdb()))) -+ self.assertEqual(realpath(b'/'.join([b'..'] * 100), **kwargs), b'/') - - @unittest.skipUnless(hasattr(os, "symlink"), - "Missing symlink implementation") - @skip_if_ABSTFN_contains_backslash -- def test_realpath_basic(self): -+ @_parameterize({}, {'strict': ALLOW_MISSING}) -+ def test_realpath_basic(self, kwargs): - # Basic operation. - try: - os.symlink(ABSTFN+"1", ABSTFN) -- self.assertEqual(realpath(ABSTFN), ABSTFN+"1") -+ self.assertEqual(realpath(ABSTFN, **kwargs), ABSTFN+"1") - finally: - support.unlink(ABSTFN) - -@@ -363,19 +389,118 @@ class PosixPathTest(unittest.TestCase): - finally: - support.unlink(ABSTFN) - -+ def test_realpath_invalid_paths(self): -+ path = '/\x00' -+ self.assertRaises(ValueError, realpath, path, strict=False) -+ self.assertRaises(ValueError, realpath, path, strict=True) -+ self.assertRaises(ValueError, realpath, path, strict=ALLOW_MISSING) -+ path = b'/\x00' -+ self.assertRaises(ValueError, realpath, path, strict=False) -+ self.assertRaises(ValueError, realpath, path, strict=True) -+ self.assertRaises(ValueError, realpath, path, strict=ALLOW_MISSING) -+ path = '/nonexistent/x\x00' -+ self.assertRaises(ValueError, realpath, path, strict=False) -+ self.assertRaises(FileNotFoundError, realpath, path, strict=True) -+ self.assertRaises(ValueError, realpath, path, strict=ALLOW_MISSING) -+ path = b'/nonexistent/x\x00' -+ self.assertRaises(ValueError, realpath, path, strict=False) -+ self.assertRaises(FileNotFoundError, realpath, path, strict=True) -+ self.assertRaises(ValueError, realpath, path, strict=ALLOW_MISSING) -+ path = '/\x00/..' -+ self.assertRaises(ValueError, realpath, path, strict=False) -+ self.assertRaises(ValueError, realpath, path, strict=True) -+ self.assertRaises(ValueError, realpath, path, strict=ALLOW_MISSING) -+ path = b'/\x00/..' -+ self.assertRaises(ValueError, realpath, path, strict=False) -+ self.assertRaises(ValueError, realpath, path, strict=True) -+ self.assertRaises(ValueError, realpath, path, strict=ALLOW_MISSING) -+ -+ path = '/nonexistent/x\x00/..' -+ self.assertRaises(ValueError, realpath, path, strict=False) -+ self.assertRaises(FileNotFoundError, realpath, path, strict=True) -+ self.assertRaises(ValueError, realpath, path, strict=ALLOW_MISSING) -+ path = b'/nonexistent/x\x00/..' -+ self.assertRaises(ValueError, realpath, path, strict=False) -+ self.assertRaises(FileNotFoundError, realpath, path, strict=True) -+ self.assertRaises(ValueError, realpath, path, strict=ALLOW_MISSING) -+ -+ path = '/\udfff' -+ if sys.platform == 'win32': -+ self.assertEqual(realpath(path, strict=False), path) -+ self.assertRaises(FileNotFoundError, realpath, path, strict=True) -+ self.assertEqual(realpath(path, strict=ALLOW_MISSING), path) -+ else: -+ self.assertRaises(UnicodeEncodeError, realpath, path, strict=False) -+ self.assertRaises(UnicodeEncodeError, realpath, path, strict=True) -+ self.assertRaises(UnicodeEncodeError, realpath, path, strict=ALLOW_MISSING) -+ path = '/nonexistent/\udfff' -+ if sys.platform == 'win32': -+ self.assertEqual(realpath(path, strict=False), path) -+ self.assertEqual(realpath(path, strict=ALLOW_MISSING), path) -+ else: -+ self.assertRaises(UnicodeEncodeError, realpath, path, strict=False) -+ self.assertRaises(UnicodeEncodeError, realpath, path, strict=ALLOW_MISSING) -+ self.assertRaises(FileNotFoundError, realpath, path, strict=True) -+ path = '/\udfff/..' -+ if sys.platform == 'win32': -+ self.assertEqual(realpath(path, strict=False), '/') -+ self.assertRaises(FileNotFoundError, realpath, path, strict=True) -+ self.assertEqual(realpath(path, strict=ALLOW_MISSING), '/') -+ else: -+ self.assertRaises(UnicodeEncodeError, realpath, path, strict=False) -+ self.assertRaises(UnicodeEncodeError, realpath, path, strict=True) -+ self.assertRaises(UnicodeEncodeError, realpath, path, strict=ALLOW_MISSING) -+ path = '/nonexistent/\udfff/..' -+ if sys.platform == 'win32': -+ self.assertEqual(realpath(path, strict=False), '/nonexistent') -+ self.assertEqual(realpath(path, strict=ALLOW_MISSING), '/nonexistent') -+ else: -+ self.assertRaises(UnicodeEncodeError, realpath, path, strict=False) -+ self.assertRaises(UnicodeEncodeError, realpath, path, strict=ALLOW_MISSING) -+ self.assertRaises(FileNotFoundError, realpath, path, strict=True) -+ -+ path = b'/\xff' -+ if sys.platform == 'win32': -+ self.assertRaises(UnicodeDecodeError, realpath, path, strict=False) -+ self.assertRaises(UnicodeDecodeError, realpath, path, strict=True) -+ self.assertRaises(UnicodeDecodeError, realpath, path, strict=ALLOW_MISSING) -+ else: -+ self.assertEqual(realpath(path, strict=False), path) -+ self.assertRaises(FileNotFoundError, realpath, path, strict=True) -+ self.assertEqual(realpath(path, strict=ALLOW_MISSING), path) -+ path = b'/nonexistent/\xff' -+ if sys.platform == 'win32': -+ self.assertRaises(UnicodeDecodeError, realpath, path, strict=False) -+ self.assertRaises(UnicodeDecodeError, realpath, path, strict=ALLOW_MISSING) -+ else: -+ self.assertEqual(realpath(path, strict=False), path) -+ self.assertRaises(FileNotFoundError, realpath, path, strict=True) -+ - @unittest.skipUnless(hasattr(os, "symlink"), - "Missing symlink implementation") - @skip_if_ABSTFN_contains_backslash -- def test_realpath_relative(self): -+ @_parameterize({}, {'strict': ALLOW_MISSING}) -+ def test_realpath_relative(self, kwargs): - try: - os.symlink(posixpath.relpath(ABSTFN+"1"), ABSTFN) -- self.assertEqual(realpath(ABSTFN), ABSTFN+"1") -+ self.assertEqual(realpath(ABSTFN, **kwargs), ABSTFN+"1") - finally: - support.unlink(ABSTFN) - - @unittest.skipUnless(hasattr(os, "symlink"), - "Missing symlink implementation") - @skip_if_ABSTFN_contains_backslash -+ @_parameterize({}, {'strict': ALLOW_MISSING}) -+ def test_realpath_missing_pardir(self, kwargs): -+ try: -+ os.symlink(support.TESTFN + "1", support.TESTFN) -+ self.assertEqual( -+ realpath("nonexistent/../" + support.TESTFN, **kwargs), ABSTFN + "1") -+ finally: -+ support.unlink(support.TESTFN) -+ -+ @support.skip_unless_symlink -+ @skip_if_ABSTFN_contains_backslash - def test_realpath_symlink_loops(self): - # Bug #930024, return the path unchanged if we get into an infinite - # symlink loop in non-strict mode (default). -@@ -418,37 +543,38 @@ class PosixPathTest(unittest.TestCase): - @unittest.skipUnless(hasattr(os, "symlink"), - "Missing symlink implementation") - @skip_if_ABSTFN_contains_backslash -- def test_realpath_symlink_loops_strict(self): -+ @_parameterize({'strict': True}, {'strict': ALLOW_MISSING}) -+ def test_realpath_symlink_loops_strict(self, kwargs): - # Bug #43757, raise OSError if we get into an infinite symlink loop in -- # strict mode. -+ # the strict modes. - try: - os.symlink(ABSTFN, ABSTFN) -- self.assertRaises(OSError, realpath, ABSTFN, strict=True) -+ self.assertRaises(OSError, realpath, ABSTFN, **kwargs) - - os.symlink(ABSTFN+"1", ABSTFN+"2") - os.symlink(ABSTFN+"2", ABSTFN+"1") -- self.assertRaises(OSError, realpath, ABSTFN+"1", strict=True) -- self.assertRaises(OSError, realpath, ABSTFN+"2", strict=True) -+ self.assertRaises(OSError, realpath, ABSTFN+"1", **kwargs) -+ self.assertRaises(OSError, realpath, ABSTFN+"2", **kwargs) - -- self.assertRaises(OSError, realpath, ABSTFN+"1/x", strict=True) -- self.assertRaises(OSError, realpath, ABSTFN+"1/..", strict=True) -- self.assertRaises(OSError, realpath, ABSTFN+"1/../x", strict=True) -+ self.assertRaises(OSError, realpath, ABSTFN+"1/x", **kwargs) -+ self.assertRaises(OSError, realpath, ABSTFN+"1/..", **kwargs) -+ self.assertRaises(OSError, realpath, ABSTFN+"1/../x", **kwargs) - os.symlink(ABSTFN+"x", ABSTFN+"y") - self.assertRaises(OSError, realpath, -- ABSTFN+"1/../" + basename(ABSTFN) + "y", strict=True) -+ ABSTFN+"1/../" + basename(ABSTFN) + "y", **kwargs) - self.assertRaises(OSError, realpath, -- ABSTFN+"1/../" + basename(ABSTFN) + "1", strict=True) -+ ABSTFN+"1/../" + basename(ABSTFN) + "1", **kwargs) - - os.symlink(basename(ABSTFN) + "a/b", ABSTFN+"a") -- self.assertRaises(OSError, realpath, ABSTFN+"a", strict=True) -+ self.assertRaises(OSError, realpath, ABSTFN+"a", **kwargs) - - os.symlink("../" + basename(dirname(ABSTFN)) + "/" + - basename(ABSTFN) + "c", ABSTFN+"c") -- self.assertRaises(OSError, realpath, ABSTFN+"c", strict=True) -+ self.assertRaises(OSError, realpath, ABSTFN+"c", **kwargs) - - # Test using relative path as well. - with support.change_cwd(dirname(ABSTFN)): -- self.assertRaises(OSError, realpath, basename(ABSTFN), strict=True) -+ self.assertRaises(OSError, realpath, basename(ABSTFN), **kwargs) - finally: - support.unlink(ABSTFN) - support.unlink(ABSTFN+"1") -@@ -460,13 +586,14 @@ class PosixPathTest(unittest.TestCase): - @unittest.skipUnless(hasattr(os, "symlink"), - "Missing symlink implementation") - @skip_if_ABSTFN_contains_backslash -- def test_realpath_repeated_indirect_symlinks(self): -+ @_parameterize({}, {'strict': True}, {'strict': ALLOW_MISSING}) -+ def test_realpath_repeated_indirect_symlinks(self, kwargs): - # Issue #6975. - try: - os.mkdir(ABSTFN) - os.symlink('../' + basename(ABSTFN), ABSTFN + '/self') - os.symlink('self/self/self', ABSTFN + '/link') -- self.assertEqual(realpath(ABSTFN + '/link'), ABSTFN) -+ self.assertEqual(realpath(ABSTFN + '/link', **kwargs), ABSTFN) - finally: - support.unlink(ABSTFN + '/self') - support.unlink(ABSTFN + '/link') -@@ -475,14 +602,15 @@ class PosixPathTest(unittest.TestCase): - @unittest.skipUnless(hasattr(os, "symlink"), - "Missing symlink implementation") - @skip_if_ABSTFN_contains_backslash -- def test_realpath_deep_recursion(self): -+ @_parameterize({}, {'strict': True}, {'strict': ALLOW_MISSING}) -+ def test_realpath_deep_recursion(self, kwargs): - depth = 10 - try: - os.mkdir(ABSTFN) - for i in range(depth): - os.symlink('/'.join(['%d' % i] * 10), ABSTFN + '/%d' % (i + 1)) - os.symlink('.', ABSTFN + '/0') -- self.assertEqual(realpath(ABSTFN + '/%d' % depth), ABSTFN) -+ self.assertEqual(realpath(ABSTFN + '/%d' % depth, **kwargs), ABSTFN) - - # Test using relative path as well. - with support.change_cwd(ABSTFN): -@@ -495,7 +623,8 @@ class PosixPathTest(unittest.TestCase): - @unittest.skipUnless(hasattr(os, "symlink"), - "Missing symlink implementation") - @skip_if_ABSTFN_contains_backslash -- def test_realpath_resolve_parents(self): -+ @_parameterize({}, {'strict': ALLOW_MISSING}) -+ def test_realpath_resolve_parents(self, kwargs): - # We also need to resolve any symlinks in the parents of a relative - # path passed to realpath. E.g.: current working directory is - # /usr/doc with 'doc' being a symlink to /usr/share/doc. We call -@@ -506,7 +635,8 @@ class PosixPathTest(unittest.TestCase): - os.symlink(ABSTFN + "/y", ABSTFN + "/k") - - with support.change_cwd(ABSTFN + "/k"): -- self.assertEqual(realpath("a"), ABSTFN + "/y/a") -+ self.assertEqual(realpath("a", **kwargs), -+ ABSTFN + "/y/a") - finally: - support.unlink(ABSTFN + "/k") - safe_rmdir(ABSTFN + "/y") -@@ -515,7 +645,8 @@ class PosixPathTest(unittest.TestCase): - @unittest.skipUnless(hasattr(os, "symlink"), - "Missing symlink implementation") - @skip_if_ABSTFN_contains_backslash -- def test_realpath_resolve_before_normalizing(self): -+ @_parameterize({}, {'strict': True}, {'strict': ALLOW_MISSING}) -+ def test_realpath_resolve_before_normalizing(self, kwargs): - # Bug #990669: Symbolic links should be resolved before we - # normalize the path. E.g.: if we have directories 'a', 'k' and 'y' - # in the following hierarchy: -@@ -530,10 +661,10 @@ class PosixPathTest(unittest.TestCase): - os.symlink(ABSTFN + "/k/y", ABSTFN + "/link-y") - - # Absolute path. -- self.assertEqual(realpath(ABSTFN + "/link-y/.."), ABSTFN + "/k") -+ self.assertEqual(realpath(ABSTFN + "/link-y/..", **kwargs), ABSTFN + "/k") - # Relative path. - with support.change_cwd(dirname(ABSTFN)): -- self.assertEqual(realpath(basename(ABSTFN) + "/link-y/.."), -+ self.assertEqual(realpath(basename(ABSTFN) + "/link-y/..", **kwargs), - ABSTFN + "/k") - finally: - support.unlink(ABSTFN + "/link-y") -@@ -544,7 +675,8 @@ class PosixPathTest(unittest.TestCase): - @unittest.skipUnless(hasattr(os, "symlink"), - "Missing symlink implementation") - @skip_if_ABSTFN_contains_backslash -- def test_realpath_resolve_first(self): -+ @_parameterize({}, {'strict': True}, {'strict': ALLOW_MISSING}) -+ def test_realpath_resolve_first(self, kwargs): - # Bug #1213894: The first component of the path, if not absolute, - # must be resolved too. - -@@ -554,13 +686,70 @@ class PosixPathTest(unittest.TestCase): - os.symlink(ABSTFN, ABSTFN + "link") - with support.change_cwd(dirname(ABSTFN)): - base = basename(ABSTFN) -- self.assertEqual(realpath(base + "link"), ABSTFN) -- self.assertEqual(realpath(base + "link/k"), ABSTFN + "/k") -+ self.assertEqual(realpath(base + "link", **kwargs), ABSTFN) -+ self.assertEqual(realpath(base + "link/k", **kwargs), ABSTFN + "/k") - finally: - support.unlink(ABSTFN + "link") - safe_rmdir(ABSTFN + "/k") - safe_rmdir(ABSTFN) - -+ @support.skip_unless_symlink -+ @skip_if_ABSTFN_contains_backslash -+ @unittest.skipIf(os.chmod not in os.supports_follow_symlinks, "Can't set symlink permissions") -+ @unittest.skipIf(sys.platform != "darwin", "only macOS requires read permission to readlink()") -+ @_parameterize({'strict': True}, {'strict': ALLOW_MISSING}) -+ def test_realpath_unreadable_symlink_strict(self, kwargs): -+ try: -+ os.symlink(ABSTFN+"1", ABSTFN) -+ os.chmod(ABSTFN, 0o000, follow_symlinks=False) -+ with self.assertRaises(PermissionError): -+ realpath(ABSTFN, **kwargs) -+ with self.assertRaises(PermissionError): -+ realpath(ABSTFN + '/foo', **kwargs), -+ with self.assertRaises(PermissionError): -+ realpath(ABSTFN + '/../foo', **kwargs) -+ with self.assertRaises(PermissionError): -+ realpath(ABSTFN + '/foo/..', **kwargs) -+ finally: -+ os.chmod(ABSTFN, 0o755, follow_symlinks=False) -+ os.unlink(ABSTFN) -+ -+ @skip_if_ABSTFN_contains_backslash -+ @support.skip_unless_symlink -+ def test_realpath_unreadable_directory(self): -+ try: -+ os.mkdir(ABSTFN) -+ os.mkdir(ABSTFN + '/k') -+ os.chmod(ABSTFN, 0o000) -+ self.assertEqual(realpath(ABSTFN, strict=False), ABSTFN) -+ self.assertEqual(realpath(ABSTFN, strict=True), ABSTFN) -+ self.assertEqual(realpath(ABSTFN, strict=ALLOW_MISSING), ABSTFN) -+ -+ try: -+ os.stat(ABSTFN) -+ except PermissionError: -+ pass -+ else: -+ self.skipTest('Cannot block permissions') -+ -+ self.assertEqual(realpath(ABSTFN + '/k', strict=False), -+ ABSTFN + '/k') -+ self.assertRaises(PermissionError, realpath, ABSTFN + '/k', -+ strict=True) -+ self.assertRaises(PermissionError, realpath, ABSTFN + '/k', -+ strict=ALLOW_MISSING) -+ -+ self.assertEqual(realpath(ABSTFN + '/missing', strict=False), -+ ABSTFN + '/missing') -+ self.assertRaises(PermissionError, realpath, ABSTFN + '/missing', -+ strict=True) -+ self.assertRaises(PermissionError, realpath, ABSTFN + '/missing', -+ strict=ALLOW_MISSING) -+ finally: -+ os.chmod(ABSTFN, 0o755) -+ safe_rmdir(ABSTFN + '/k') -+ safe_rmdir(ABSTFN) -+ - def test_relpath(self): - (real_getcwd, os.getcwd) = (os.getcwd, lambda: r"/home/user/bar") - try: -@@ -737,9 +926,12 @@ class PathLikeTests(unittest.TestCase): - def test_path_abspath(self): - self.assertPathEqual(self.path.abspath) - -- def test_path_realpath(self): -+ @_parameterize({}, {'strict': True}, {'strict': ALLOW_MISSING}) -+ def test_path_realpath(self, kwargs): - self.assertPathEqual(self.path.realpath) - -+ self.assertPathEqual(partial(self.path.realpath, **kwargs)) -+ - def test_path_relpath(self): - self.assertPathEqual(self.path.relpath) - -diff --git a/Lib/test/test_tarfile.py b/Lib/test/test_tarfile.py -index 23358c5..29f65d0 100644 ---- a/Lib/test/test_tarfile.py -+++ b/Lib/test/test_tarfile.py -@@ -2438,9 +2438,35 @@ class MiscTest(unittest.TestCase): - 'tar_filter', 'FilterError', 'AbsoluteLinkError', - 'OutsideDestinationError', 'SpecialFileError', - 'AbsolutePathError', 'LinkOutsideDestinationError', -+ 'LinkFallbackError', - } - support.check__all__(self, tarfile, blacklist=blacklist) - -+ @unittest.skipUnless(support.can_symlink(), 'requires symlink support') -+ @unittest.skipUnless(hasattr(os, 'chmod'), "missing os.chmod") -+ @unittest.mock.patch('os.chmod') -+ def test_deferred_directory_attributes_update(self, mock_chmod): -+ # Regression test for gh-127987: setting attributes on arbitrary files -+ tempdir = os.path.join(TEMPDIR, 'test127987') -+ def mock_chmod_side_effect(path, mode, **kwargs): -+ target_path = os.path.realpath(path) -+ if os.path.commonpath([target_path, tempdir]) != tempdir: -+ raise Exception("should not try to chmod anything outside the destination", target_path) -+ mock_chmod.side_effect = mock_chmod_side_effect -+ -+ outside_tree_dir = os.path.join(TEMPDIR, 'outside_tree_dir') -+ with ArchiveMaker() as arc: -+ arc.add('x', symlink_to='.') -+ arc.add('x', type=tarfile.DIRTYPE, mode='?rwsrwsrwt') -+ arc.add('x', symlink_to=outside_tree_dir) -+ -+ os.makedirs(outside_tree_dir) -+ try: -+ arc.open().extractall(path=tempdir, filter='tar') -+ finally: -+ support.rmtree(outside_tree_dir) -+ support.rmtree(tempdir) -+ - - class CommandLineTest(unittest.TestCase): - -@@ -2990,6 +3016,10 @@ class NoneInfoExtractTests(ReadTest): - got_paths = set( - p.relative_to(directory) - for p in pathlib.Path(directory).glob('**/*')) -+ if self.extraction_filter == 'data': -+ # The 'data' filter is expected to reject special files -+ for path in 'ustar/fifotype', 'ustar/blktype', 'ustar/chrtype': -+ got_paths.discard(pathlib.Path(path)) - self.assertEqual(self.control_paths, got_paths) - - @contextmanager -@@ -3216,12 +3246,28 @@ class ArchiveMaker: - self.bio = None - - def add(self, name, *, type=None, symlink_to=None, hardlink_to=None, -- mode=None, size=None, **kwargs): -- """Add a member to the test archive. Call within `with`.""" -+ mode=None, size=None, content=None, **kwargs): -+ """Add a member to the test archive. Call within `with`. -+ -+ Provides many shortcuts: -+ - default `type` is based on symlink_to, hardlink_to, and trailing `/` -+ in name (which is stripped) -+ - size & content defaults are based on each other -+ - content can be str or bytes -+ - mode should be textual ('-rwxrwxrwx') -+ -+ (add more! this is unstable internal test-only API) -+ """ - name = str(name) - tarinfo = tarfile.TarInfo(name).replace(**kwargs) -+ if content is not None: -+ if isinstance(content, str): -+ content = content.encode() -+ size = len(content) - if size is not None: - tarinfo.size = size -+ if content is None: -+ content = bytes(tarinfo.size) - if mode: - tarinfo.mode = _filemode_to_int(mode) - if symlink_to is not None: -@@ -3235,7 +3281,7 @@ class ArchiveMaker: - if type is not None: - tarinfo.type = type - if tarinfo.isreg(): -- fileobj = io.BytesIO(bytes(tarinfo.size)) -+ fileobj = io.BytesIO(content) - else: - fileobj = None - self.tar_w.addfile(tarinfo, fileobj) -@@ -3257,7 +3303,7 @@ class TestExtractionFilters(unittest.TestCase): - destdir = outerdir / 'dest' - - @contextmanager -- def check_context(self, tar, filter): -+ def check_context(self, tar, filter, *, check_flag=True, ignored_trees=()): - """Extracts `tar` to `self.destdir` and allows checking the result - - If an error occurs, it must be checked using `expect_exception` -@@ -3266,27 +3312,46 @@ class TestExtractionFilters(unittest.TestCase): - except the destination directory itself and parent directories of - other files. - When checking directories, do so before their contents. -+ -+ A file called 'flag' is made in outerdir (i.e. outside destdir) -+ before extraction; it should not be altered nor should its contents -+ be read/copied. -+ -+ *ignored_trees* is a set of directories to remove (including their -+ contents) right after the archive is extracted. It is a workaround -+ for Path.glob() failing to get all files in Python 3.10 and below. - """ - with support.temp_dir(self.outerdir): -+ flag_path = self.outerdir / 'flag' -+ flag_path.write_text('capture me') - try: - tar.extractall(self.destdir, filter=filter) - except Exception as exc: - self.raised_exception = exc -+ self.reraise_exception = True - self.expected_paths = set() - else: -+ for ignored_tree in ignored_trees: -+ support.rmtree((self.destdir / ignored_tree).resolve()) - self.raised_exception = None -+ self.reraise_exception = False - self.expected_paths = set(self.outerdir.glob('**/*')) - self.expected_paths.discard(self.destdir) -+ self.expected_paths.discard(flag_path) - try: -- yield -+ yield self - finally: - tar.close() -- if self.raised_exception: -+ if self.reraise_exception: - raise self.raised_exception - self.assertEqual(self.expected_paths, set()) -+ if check_flag: -+ self.assertEqual(flag_path.read_text(), 'capture me') -+ else: -+ assert filter == 'fully_trusted' - - def expect_file(self, name, type=None, symlink_to=None, mode=None, -- size=None): -+ size=None, content=None): - """Check a single file. See check_context.""" - if self.raised_exception: - raise self.raised_exception -@@ -3296,7 +3361,7 @@ class TestExtractionFilters(unittest.TestCase): - self.expected_paths.remove(path) - - # When checking mode, ignore Windows (which can only set user read and -- # user write bits). Newer versions of Python use `os_helper.can_chmod()` -+ # user write bits). Newer versions of Python use `support.can_chmod()` - # instead of hardcoding Windows. - if mode is not None and sys.platform != 'win32': - got = stat.filemode(stat.S_IMODE(path.stat().st_mode)) -@@ -3310,26 +3375,45 @@ class TestExtractionFilters(unittest.TestCase): - # The symlink might be the same (textually) as what we expect, - # but some systems change the link to an equivalent path, so - # we fall back to samefile(). -- if expected != got: -- self.assertTrue(got.samefile(expected)) -+ try: -+ if expected != got: -+ self.assertTrue(got.samefile(expected)) -+ except Exception as e: -+ # attach a note, so it's shown even if `samefile` fails -+ e.add_note(f'{expected=}, {got=}') -+ raise - elif type == tarfile.REGTYPE or type is None: - self.assertTrue(path.is_file()) - elif type == tarfile.DIRTYPE: - self.assertTrue(path.is_dir()) - elif type == tarfile.FIFOTYPE: - self.assertTrue(path.is_fifo()) -+ elif type == tarfile.SYMTYPE: -+ self.assertTrue(path.is_symlink()) - else: - raise NotImplementedError(type) - if size is not None: - self.assertEqual(path.stat().st_size, size) -+ if content is not None: -+ self.assertEqual(path.read_text(), content) - for parent in path.parents: - self.expected_paths.discard(parent) - -+ def expect_any_tree(self, name): -+ """Check a directory; forget about its contents.""" -+ tree_path = (self.destdir / name).resolve() -+ self.expect_file(tree_path, type=tarfile.DIRTYPE) -+ self.expected_paths = { -+ p for p in self.expected_paths -+ if tree_path not in p.parents -+ } -+ - def expect_exception(self, exc_type, message_re='.'): - with self.assertRaisesRegex(exc_type, message_re): - if self.raised_exception is not None: - raise self.raised_exception -- self.raised_exception = None -+ self.reraise_exception = False -+ return self.raised_exception - - def test_benign_file(self): - with ArchiveMaker() as arc: -@@ -3413,6 +3497,78 @@ class TestExtractionFilters(unittest.TestCase): - with self.check_context(arc.open(), 'data'): - self.expect_file('parent/evil') - -+ @support.skip_unless_symlink -+ def test_realpath_limit_attack(self): -+ # (CVE-2025-4517) -+ -+ with ArchiveMaker() as arc: -+ # populate the symlinks and dirs that expand in os.path.realpath() -+ # The component length is chosen so that in common cases, the unexpanded -+ # path fits in PATH_MAX, but it overflows when the final symlink -+ # is expanded -+ steps = "abcdefghijklmnop" -+ if sys.platform == 'win32': -+ component = 'd' * 25 -+ elif 'PC_PATH_MAX' in os.pathconf_names: -+ max_path_len = os.pathconf(self.outerdir.parent, "PC_PATH_MAX") -+ path_sep_len = 1 -+ dest_len = len(str(self.destdir)) + path_sep_len -+ component_len = (max_path_len - dest_len) // (len(steps) + path_sep_len) -+ component = 'd' * component_len -+ else: -+ raise NotImplementedError("Need to guess component length for {sys.platform}") -+ path = "" -+ step_path = "" -+ for i in steps: -+ arc.add(os.path.join(path, component), type=tarfile.DIRTYPE, -+ mode='drwxrwxrwx') -+ arc.add(os.path.join(path, i), symlink_to=component) -+ path = os.path.join(path, component) -+ step_path = os.path.join(step_path, i) -+ # create the final symlink that exceeds PATH_MAX and simply points -+ # to the top dir. -+ # this link will never be expanded by -+ # os.path.realpath(strict=False), nor anything after it. -+ linkpath = os.path.join(*steps, "l"*254) -+ parent_segments = [".."] * len(steps) -+ arc.add(linkpath, symlink_to=os.path.join(*parent_segments)) -+ # make a symlink outside to keep the tar command happy -+ arc.add("escape", symlink_to=os.path.join(linkpath, "..")) -+ # use the symlinks above, that are not checked, to create a hardlink -+ # to a file outside of the destination path -+ arc.add("flaglink", hardlink_to=os.path.join("escape", "flag")) -+ # now that we have the hardlink we can overwrite the file -+ arc.add("flaglink", content='overwrite') -+ # we can also create new files as well! -+ arc.add("escape/newfile", content='new') -+ -+ with (self.subTest('fully_trusted'), -+ self.check_context(arc.open(), filter='fully_trusted', -+ check_flag=False, ignored_trees={component})): -+ if sys.platform == 'win32': -+ self.expect_exception((FileNotFoundError, FileExistsError)) -+ elif self.raised_exception: -+ # Cannot symlink/hardlink: tarfile falls back to getmember() -+ self.expect_exception(KeyError) -+ # Otherwise, this block should never enter. -+ else: -+ self.expect_file('flaglink', content='overwrite') -+ self.expect_file('../newfile', content='new') -+ self.expect_file('escape', type=tarfile.SYMTYPE) -+ self.expect_file('a', symlink_to=component) -+ -+ for filter in 'tar', 'data': -+ with self.subTest(filter), self.check_context(arc.open(), filter=filter): -+ exc = self.expect_exception((OSError, KeyError)) -+ if isinstance(exc, OSError): -+ if sys.platform == 'win32': -+ # 3: ERROR_PATH_NOT_FOUND -+ # 5: ERROR_ACCESS_DENIED -+ # 206: ERROR_FILENAME_EXCED_RANGE -+ self.assertIn(exc.winerror, (3, 5, 206)) -+ else: -+ self.assertEqual(exc.errno, errno.ENAMETOOLONG) -+ - def test_parent_symlink2(self): - # Test interplaying symlinks - # Inspired by 'dirsymlink2b' in jwilk/traversal-archives -@@ -3629,8 +3785,8 @@ class TestExtractionFilters(unittest.TestCase): - arc.add('symlink2', symlink_to=os.path.join( - 'linkdir', 'hardlink2')) - arc.add('targetdir/target', size=3) -- arc.add('linkdir/hardlink', hardlink_to='targetdir/target') -- arc.add('linkdir/hardlink2', hardlink_to='linkdir/symlink') -+ arc.add('linkdir/hardlink', hardlink_to=os.path.join('targetdir', 'target')) -+ arc.add('linkdir/hardlink2', hardlink_to=os.path.join('linkdir', 'symlink')) - - for filter in 'tar', 'data', 'fully_trusted': - with self.check_context(arc.open(), filter): -@@ -3646,6 +3802,126 @@ class TestExtractionFilters(unittest.TestCase): - self.expect_file('linkdir/symlink', size=3) - self.expect_file('symlink2', size=3) - -+ def test_sneaky_hardlink_fallback(self): -+ # (CVE-2025-4330) -+ # Test that when hardlink extraction falls back to extracting members -+ # from the archive, the extracted member is (re-)filtered. -+ with ArchiveMaker() as arc: -+ # Create a directory structure so the c/escape symlink stays -+ # inside the path -+ arc.add("a/t/dummy") -+ # Create b/ directory -+ arc.add("b/") -+ # Point "c" to the bottom of the tree in "a" -+ arc.add("c", symlink_to=os.path.join("a", "t")) -+ # link to non-existant location under "a" -+ arc.add("c/escape", symlink_to=os.path.join("..", "..", -+ "link_here")) -+ # Move "c" to point to "b" ("c/escape" no longer exists) -+ arc.add("c", symlink_to="b") -+ # Attempt to create a hard link to "c/escape". Since it doesn't -+ # exist it will attempt to extract "cescape" but at "boom". -+ arc.add("boom", hardlink_to=os.path.join("c", "escape")) -+ -+ with self.check_context(arc.open(), 'data'): -+ if not support.can_symlink(): -+ # When 'c/escape' is extracted, 'c' is a regular -+ # directory, and 'c/escape' *would* point outside -+ # the destination if symlinks were allowed. -+ self.expect_exception( -+ tarfile.LinkOutsideDestinationError) -+ elif sys.platform == "win32": -+ # On Windows, 'c/escape' points outside the destination -+ self.expect_exception(tarfile.LinkOutsideDestinationError) -+ else: -+ e = self.expect_exception( -+ tarfile.LinkFallbackError, -+ "link 'boom' would be extracted as a copy of " -+ + "'c/escape', which was rejected") -+ self.assertIsInstance(e.__cause__, -+ tarfile.LinkOutsideDestinationError) -+ for filter in 'tar', 'fully_trusted': -+ with self.subTest(filter), self.check_context(arc.open(), filter): -+ if not support.can_symlink(): -+ self.expect_file("a/t/dummy") -+ self.expect_file("b/") -+ self.expect_file("c/") -+ else: -+ self.expect_file("a/t/dummy") -+ self.expect_file("b/") -+ self.expect_file("a/t/escape", symlink_to='../../link_here') -+ self.expect_file("boom", symlink_to='../../link_here') -+ self.expect_file("c", symlink_to='b') -+ -+ def test_exfiltration_via_symlink(self): -+ # (CVE-2025-4138) -+ # Test changing symlinks that result in a symlink pointing outside -+ # the extraction directory, unless prevented by 'data' filter's -+ # normalization. -+ with ArchiveMaker() as arc: -+ arc.add("escape", symlink_to=os.path.join('link', 'link', '..', '..', 'link-here')) -+ arc.add("link", symlink_to='./') -+ -+ for filter in 'tar', 'data', 'fully_trusted': -+ with self.check_context(arc.open(), filter): -+ if support.can_symlink(): -+ self.expect_file("link", symlink_to='./') -+ if filter == 'data': -+ self.expect_file("escape", symlink_to='link-here') -+ else: -+ self.expect_file("escape", -+ symlink_to='link/link/../../link-here') -+ else: -+ # Nothing is extracted. -+ pass -+ -+ def test_chmod_outside_dir(self): -+ # (CVE-2024-12718) -+ # Test that members used for delayed updates of directory metadata -+ # are (re-)filtered. -+ with ArchiveMaker() as arc: -+ # "pwn" is a veeeery innocent symlink: -+ arc.add("a/pwn", symlink_to='.') -+ # But now "pwn" is also a directory, so it's scheduled to have its -+ # metadata updated later: -+ arc.add("a/pwn/", mode='drwxrwxrwx') -+ # Oops, "pwn" is not so innocent any more: -+ arc.add("a/pwn", symlink_to='x/../') -+ # Newly created symlink points to the dest dir, -+ # so it's OK for the "data" filter. -+ arc.add('a/x', symlink_to=('../')) -+ # But now "pwn" points outside the dest dir -+ -+ for filter in 'tar', 'data', 'fully_trusted': -+ with self.check_context(arc.open(), filter) as cc: -+ if not support.can_symlink(): -+ self.expect_file("a/pwn/") -+ elif filter == 'data': -+ self.expect_file("a/x", symlink_to='../') -+ self.expect_file("a/pwn", symlink_to='.') -+ else: -+ self.expect_file("a/x", symlink_to='../') -+ self.expect_file("a/pwn", symlink_to='x/../') -+ if sys.platform != "win32": -+ st_mode = cc.outerdir.stat().st_mode -+ self.assertNotEqual(st_mode & 0o777, 0o777) -+ -+ def test_link_fallback_normalizes(self): -+ # Make sure hardlink fallbacks work for non-normalized paths for all -+ # filters -+ with ArchiveMaker() as arc: -+ arc.add("dir/") -+ arc.add("dir/../afile") -+ arc.add("link1", hardlink_to='dir/../afile') -+ arc.add("link2", hardlink_to='dir/../dir/../afile') -+ -+ for filter in 'tar', 'data', 'fully_trusted': -+ with self.check_context(arc.open(), filter) as cc: -+ self.expect_file("dir/") -+ self.expect_file("afile") -+ self.expect_file("link1") -+ self.expect_file("link2") -+ - def test_modes(self): - # Test how file modes are extracted - # (Note that the modes are ignored on platforms without working chmod) -@@ -3770,7 +4046,7 @@ class TestExtractionFilters(unittest.TestCase): - # The 'tar' filter returns TarInfo objects with the same name/type. - # (It can also fail for particularly "evil" input, but we don't have - # that in the test archive.) -- with tarfile.TarFile.open(tarname) as tar: -+ with tarfile.TarFile.open(tarname, encoding="iso8859-1") as tar: - for tarinfo in tar.getmembers(): - filtered = tarfile.tar_filter(tarinfo, '') - self.assertIs(filtered.name, tarinfo.name) -@@ -3779,7 +4055,7 @@ class TestExtractionFilters(unittest.TestCase): - def test_data_filter(self): - # The 'data' filter either raises, or returns TarInfo with the same - # name/type. -- with tarfile.TarFile.open(tarname) as tar: -+ with tarfile.TarFile.open(tarname, encoding="iso8859-1") as tar: - for tarinfo in tar.getmembers(): - try: - filtered = tarfile.data_filter(tarinfo, '') -@@ -3909,13 +4185,13 @@ class TestExtractionFilters(unittest.TestCase): - # If errorlevel is 0, errors affected by errorlevel are ignored - - with self.check_context(arc.open(errorlevel=0), extracterror_filter): -- self.expect_file('file') -+ pass - - with self.check_context(arc.open(errorlevel=0), filtererror_filter): -- self.expect_file('file') -+ pass - - with self.check_context(arc.open(errorlevel=0), oserror_filter): -- self.expect_file('file') -+ pass - - with self.check_context(arc.open(errorlevel=0), tarerror_filter): - self.expect_exception(tarfile.TarError) -@@ -3926,7 +4202,7 @@ class TestExtractionFilters(unittest.TestCase): - # If 1, all fatal errors are raised - - with self.check_context(arc.open(errorlevel=1), extracterror_filter): -- self.expect_file('file') -+ pass - - with self.check_context(arc.open(errorlevel=1), filtererror_filter): - self.expect_exception(tarfile.FilterError) -diff --git a/Misc/NEWS.d/next/Security/2025-06-02-11-32-23.gh-issue-135034.RLGjbp.rst b/Misc/NEWS.d/next/Security/2025-06-02-11-32-23.gh-issue-135034.RLGjbp.rst -new file mode 100644 -index 0000000..e3f984a ---- /dev/null -+++ b/Misc/NEWS.d/next/Security/2025-06-02-11-32-23.gh-issue-135034.RLGjbp.rst -@@ -0,0 +1,6 @@ -+Fixes multiple issues that allowed ``tarfile`` extraction filters -+(``filter="data"`` and ``filter="tar"``) to be bypassed using crafted -+symlinks and hard links. -+ -+Addresses CVE 2024-12718, CVE 2025-4138, CVE 2025-4330, and CVE 2025-4517. -+ --- -2.49.0 - diff --git a/SOURCES/Python-3.9.21.tar.xz.asc b/SOURCES/Python-3.9.21.tar.xz.asc deleted file mode 100644 index 6e49b13..0000000 --- a/SOURCES/Python-3.9.21.tar.xz.asc +++ /dev/null @@ -1,16 +0,0 @@ ------BEGIN PGP SIGNATURE----- - -iQIzBAABCgAdFiEE4/8oOcBIslwITevpsmmV4xAlBWgFAmdPScsACgkQsmmV4xAl -BWgZtRAAiPehPRc94kRpNn4CuLw9hDFJmucXfG/Pjf9DdQkrmWAMvFS2kpigd9A0 -3QoDbgZPb8k9XtTrbpT4A0j/SYaqnLXOktXE7CEwM1vRTHbUDm62qxRSIa+RXO1d -h/EqhF1Rpgl37I1GL3mAHew6KjIq3K/aNvJVTtKA+1xy8XpF5Dbk3feDeTucqYaM -evtCu2SlwQXRvIbqFciMtRC2bmkNHgRVFpuxInjmp82ED0E6yZ/ecHXjb5Da7lDV -8uRh9aEjMWY4LHTdl2tWaaerLqYZfvHSlz2xY8W5itSgOAzzJNn3dX8P6EKK5ab7 -IV85vqPX1oMcX0seZd3QlVdOxUPf1tKB7Eo7yHz3gV/KgWzFSAHSZFCwiqyfNfj8 -PibYYwtGG0+S7GJ7bZ2iCCgkqrFBoMQ8yOEDxE7Pt36JSXtZ2grtsFB8WAZqhCMa -luIjiibGkOzzBRX/neW5RYT1HLNzi140G7XEZeRv3CXzuud66+ynLdOK7uFEr8jq -W0t/fHbrqcSjyEo9L9VDP4Wd9VU/nwf6tb3Py6nPZKM93ZYqtlJNmzxmOyQOmn30 -bRGEQyzcYmEKMWg6zzO57In/WRytB4BgE7Dj7L876TbnJ8YoR8TVpuQ3sDKoSxwo -gIdNR/6lcqP0HwDFtqBrcwSQ9Dew6wMz0Pp2EwX90EZLwnBvpeI= -=taeu ------END PGP SIGNATURE----- diff --git a/SOURCES/Python-3.9.23.tar.xz.asc b/SOURCES/Python-3.9.23.tar.xz.asc new file mode 100644 index 0000000..44ac441 --- /dev/null +++ b/SOURCES/Python-3.9.23.tar.xz.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCAAdFiEE4/8oOcBIslwITevpsmmV4xAlBWgFAmg/SAkACgkQsmmV4xAl +BWjuNA/+Ogt83qY+v8J1o1+YRcJo8w3FUhuOgCALHOd1Q562xldiVyH3Y9uo7sla +j15qz8dFzHPrJddeA993oONnyiXKvsvo8oQ1P41b5BgpifNepCTFVRq/eJA/ZJVV +Dj1g7XwsFo4tbtu9uG51msGaqNFO9OZkwtZG8ucb0njxoA0sO3kIZhbVVG8g2OKM +JLw8dxVmZaQzjCQGBqTMf5cnCwP1TFaaVaJjm1+tVga45G+TIFInwR7kowwPyoWD +7IuZwXu3syPWJP+cMjOxcykMA3HLUBhAV6o9pMvM3JWNdmrwUJRwt41LxY4Q8ZVa +QubnCB9B/uED+PakumiyH2P+gNhAOwzrq7iC8XvxW+/bsJAObTj4cj1WGY7ot+cE +kE7pNoe8NwLiCwt3ENO2QieiHFQvYL9Y+ukQwB2xl+ywsyEwa6RLOvSA5GQs+5r4 +Pv/BgE9nDIzLYVnUMtHic75pmI773pUiHuNXOqdMw4kb61GY5fG+kzVY1HEM1Y8p +krMaEeOEbBNww3Ce0TCzuS/1EhsOB8cB1w9IjtZrtJYCxiVvtdZS8S6F0Y8eJGV2 +xPPcADFv6QKogPrrkbWkwIkT+TKn9s22Dkkr0zdTZ4sTMkGAm08B0kqxbsteI+fJ +I39hHo92mP6W8PuhFJiBkF4ebm61kMwg0mElcueOZiqwW2zRey0= +=Lgxm +-----END PGP SIGNATURE----- diff --git a/SPECS/python3.9.spec b/SPECS/python3.9.spec index 8ddb236..d4c90b2 100644 --- a/SPECS/python3.9.spec +++ b/SPECS/python3.9.spec @@ -13,11 +13,11 @@ URL: https://www.python.org/ # WARNING When rebasing to a new Python version, # remember to update the python3-docs package as well -%global general_version %{pybasever}.21 +%global general_version %{pybasever}.23 #global prerel ... %global upstream_version %{general_version}%{?prerel} Version: %{general_version}%{?prerel:~%{prerel}} -Release: 2%{?dist}.2 +Release: 2%{?dist} License: Python @@ -269,6 +269,7 @@ BuildRequires: valgrind-devel BuildRequires: xz-devel BuildRequires: zlib-devel +BuildRequires: systemtap-sdt-devel BuildRequires: /usr/bin/dtrace # workaround http://bugs.python.org/issue19804 (test_uuid requires ifconfig) @@ -437,20 +438,6 @@ Patch415: 00415-cve-2023-27043-gh-102988-reject-malformed-addresses-in-email-par # CVE-2023-52425. Future versions of Expat may be more reactive. Patch422: 00422-fix-tests-for-xmlpullparser-with-expat-2-6-0.patch -# 00450 # 4ab8663661748eb994c09e4ae89f59eb84c5d3ea -# CVE-2025-0938: Disallow square brackets ([ and ]) in domain names for parsed URLs -Patch450: 00450-cve-2025-0938-disallow-square-brackets-and-in-domain-names-for-parsed-urls.patch - -# 00465 # -# Security fixes for CVE-2025-4517, CVE-2025-4330, CVE-2025-4138, CVE-2024-12718, CVE-2025-4435 on tarfile -# -# The patch consist of the following commits: -# - https://github.com/python/cpython/commit/00af9794dd118f7b835dd844b2b609a503ad951e -# adds a new "strict" argument to realpath() -# - https://github.com/python/cpython/commit/dd8f187d0746da151e0025c51680979ac5b4cfb1 -# fixes multiple CVE fixes in the tarfile module -Patch465: 00465-tarfile-cves.patch - # 00467 # # CVE-2025-8194 # @@ -1863,13 +1850,18 @@ CheckPython optimized # ====================================================== %changelog -* Tue Aug 19 2025 Lumír Balhar - 3.9.21-2.2 +* Tue Aug 19 2025 Lumír Balhar - 3.9.23-2 - Security fix for CVE-2025-8194 -Resolves: RHEL-106375 +Resolves: RHEL-106374 -* Fri Jun 27 2025 Charalampos Stratakis - 3.9.21-2.1 +* Fri Jun 27 2025 Tomáš Hrnčiar - 3.9.23-1 +- Update to 3.9.23 - Security fixes for CVE-2025-4517, CVE-2025-4330, CVE-2025-4138, CVE-2024-12718, CVE-2025-4435 -Resolves: RHEL-98053, RHEL-98025, RHEL-98243, RHEL-98195, RHEL-98219 +Resolves: RHEL-98051, RHEL-98024, RHEL-98242, RHEL-98193, RHEL-98218 + +* Mon Jun 23 2025 Tobias Urdin - 3.9.21-3 +- Add systemtap-sdt-devel build dependency +Resolves: RHEL-99500 * Mon Feb 10 2025 Charalampos Stratakis - 3.9.21-2 - Security fix for CVE-2025-0938