diff --git a/.gitignore b/.gitignore index d2618f7..acb56ac 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1 @@ -SOURCES/Python-3.12.9.tar.xz +SOURCES/Python-3.12.11.tar.xz diff --git a/.python3.12.metadata b/.python3.12.metadata index 9510f08..d8dbb41 100644 --- a/.python3.12.metadata +++ b/.python3.12.metadata @@ -1 +1 @@ -465d8a664e63dc5aa1f0d90cd1d0000a970ee2fb SOURCES/Python-3.12.9.tar.xz +603f20426ba4942552a38493bb987c9b832ee321 SOURCES/Python-3.12.11.tar.xz diff --git a/SOURCES/00251-change-user-install-location.patch b/SOURCES/00251-change-user-install-location.patch index dd7a07a..6a5402d 100644 --- a/SOURCES/00251-change-user-install-location.patch +++ b/SOURCES/00251-change-user-install-location.patch @@ -51,7 +51,7 @@ index aed254ad50..568dbdb945 100644 if os.path.isdir(sitedir): addsitedir(sitedir, known_paths) diff --git a/Lib/sysconfig.py b/Lib/sysconfig.py -index 517b13acaf..928d1a0541 100644 +index acc8d4d182..6355669f62 100644 --- a/Lib/sysconfig.py +++ b/Lib/sysconfig.py @@ -104,6 +104,11 @@ @@ -86,7 +86,7 @@ index 517b13acaf..928d1a0541 100644 _SCHEME_KEYS = ('stdlib', 'platstdlib', 'purelib', 'platlib', 'include', 'scripts', 'data') -@@ -261,11 +279,40 @@ def _extend_dict(target_dict, other_dict): +@@ -268,11 +286,40 @@ def _extend_dict(target_dict, other_dict): target_dict[key] = value @@ -129,7 +129,7 @@ index 517b13acaf..928d1a0541 100644 # On Windows we want to substitute 'lib' for schemes rather # than the native value (without modifying vars, in case it diff --git a/Lib/test/test_sysconfig.py b/Lib/test/test_sysconfig.py -index 3468d0ce02..ff31010427 100644 +index 67647e1b78..7baddaa9d6 100644 --- a/Lib/test/test_sysconfig.py +++ b/Lib/test/test_sysconfig.py @@ -119,8 +119,19 @@ def test_get_path(self): diff --git a/SOURCES/00459-apply-intel-control-flow-technology-for-x86-64.patch b/SOURCES/00459-apply-intel-control-flow-technology-for-x86-64.patch new file mode 100644 index 0000000..380856c --- /dev/null +++ b/SOURCES/00459-apply-intel-control-flow-technology-for-x86-64.patch @@ -0,0 +1,51 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Charalampos Stratakis +Date: Wed, 8 Jan 2025 04:58:22 +0100 +Subject: 00459: Apply Intel Control-flow Technology for x86-64 + +Required for mitigation against return-oriented programming (ROP) and Call or Jump Oriented Programming (COP/JOP) attacks + +Proposed upstream: https://github.com/python/cpython/pull/128606 + +See also: https://sourceware.org/annobin/annobin.html/Test-cf-protection.html +--- + Python/asm_trampoline.S | 22 ++++++++++++++++++++++ + 1 file changed, 22 insertions(+) + +diff --git a/Python/asm_trampoline.S b/Python/asm_trampoline.S +index 460707717d..341d0bbe51 100644 +--- a/Python/asm_trampoline.S ++++ b/Python/asm_trampoline.S +@@ -9,6 +9,9 @@ + # } + _Py_trampoline_func_start: + #ifdef __x86_64__ ++#if defined(__CET__) && (__CET__ & 1) ++ endbr64 ++#endif + sub $8, %rsp + call *%rcx + add $8, %rsp +@@ -26,3 +29,22 @@ _Py_trampoline_func_start: + .globl _Py_trampoline_func_end + _Py_trampoline_func_end: + .section .note.GNU-stack,"",@progbits ++# Note for indicating the assembly code supports CET ++#if defined(__x86_64__) && defined(__CET__) && (__CET__ & 1) ++ .section .note.gnu.property,"a" ++ .align 8 ++ .long 1f - 0f ++ .long 4f - 1f ++ .long 5 ++0: ++ .string "GNU" ++1: ++ .align 8 ++ .long 0xc0000002 ++ .long 3f - 2f ++2: ++ .long 0x3 ++3: ++ .align 8 ++4: ++#endif // __x86_64__ diff --git a/SOURCES/00462-fix-pyssl_seterror-handling-ssl_error_syscall.patch b/SOURCES/00462-fix-pyssl_seterror-handling-ssl_error_syscall.patch new file mode 100644 index 0000000..bfa2f8e --- /dev/null +++ b/SOURCES/00462-fix-pyssl_seterror-handling-ssl_error_syscall.patch @@ -0,0 +1,196 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: yevgeny hong +Date: Tue, 26 Mar 2024 16:45:43 +0900 +Subject: 00462: Fix PySSL_SetError handling SSL_ERROR_SYSCALL + +Python 3.10 changed from using SSL_write() and SSL_read() to SSL_write_ex() and +SSL_read_ex(), but did not update handling of the return value. + +Change error handling so that the return value is not examined. +OSError (not EOF) is now returned when retval is 0. + +This resolves the issue of failing tests when a system is +stressed on OpenSSL 3.5. + +Co-authored-by: Serhiy Storchaka +Co-authored-by: Petr Viktorin +--- + Lib/test/test_ssl.py | 28 ++++++----- + ...-02-18-09-50-31.gh-issue-115627.HGchj0.rst | 2 + + Modules/_ssl.c | 48 +++++++------------ + 3 files changed, 35 insertions(+), 43 deletions(-) + create mode 100644 Misc/NEWS.d/next/Library/2024-02-18-09-50-31.gh-issue-115627.HGchj0.rst + +diff --git a/Lib/test/test_ssl.py b/Lib/test/test_ssl.py +index b13e37d0cd..daeb8cba74 100644 +--- a/Lib/test/test_ssl.py ++++ b/Lib/test/test_ssl.py +@@ -2427,16 +2427,18 @@ def run(self): + self.write(msg.lower()) + except OSError as e: + # handles SSLError and socket errors ++ if isinstance(e, ConnectionError): ++ # OpenSSL 1.1.1 sometimes raises ++ # ConnectionResetError when connection is not ++ # shut down gracefully. ++ if self.server.chatty and support.verbose: ++ print(f" Connection reset by peer: {self.addr}") ++ ++ self.close() ++ self.running = False ++ return + if self.server.chatty and support.verbose: +- if isinstance(e, ConnectionError): +- # OpenSSL 1.1.1 sometimes raises +- # ConnectionResetError when connection is not +- # shut down gracefully. +- print( +- f" Connection reset by peer: {self.addr}" +- ) +- else: +- handle_error("Test server failure:\n") ++ handle_error("Test server failure:\n") + try: + self.write(b"ERROR\n") + except OSError: +@@ -3148,8 +3150,8 @@ def test_wrong_cert_tls13(self): + suppress_ragged_eofs=False) as s: + s.connect((HOST, server.port)) + with self.assertRaisesRegex( +- ssl.SSLError, +- 'alert unknown ca|EOF occurred|TLSV1_ALERT_UNKNOWN_CA' ++ OSError, ++ 'alert unknown ca|EOF occurred|TLSV1_ALERT_UNKNOWN_CA|closed by the remote host|Connection reset by peer' + ): + # TLS 1.3 perform client cert exchange after handshake + s.write(b'data') +@@ -4422,8 +4424,8 @@ def msg_cb(conn, direction, version, content_type, msg_type, data): + # test sometimes fails with EOF error. Test passes as long as + # server aborts connection with an error. + with self.assertRaisesRegex( +- ssl.SSLError, +- '(certificate required|EOF occurred)' ++ OSError, ++ 'certificate required|EOF occurred|closed by the remote host|Connection reset by peer' + ): + # receive CertificateRequest + data = s.recv(1024) +diff --git a/Misc/NEWS.d/next/Library/2024-02-18-09-50-31.gh-issue-115627.HGchj0.rst b/Misc/NEWS.d/next/Library/2024-02-18-09-50-31.gh-issue-115627.HGchj0.rst +new file mode 100644 +index 0000000000..75d926ab59 +--- /dev/null ++++ b/Misc/NEWS.d/next/Library/2024-02-18-09-50-31.gh-issue-115627.HGchj0.rst +@@ -0,0 +1,2 @@ ++Fix the :mod:`ssl` module error handling of connection terminate by peer. ++It now throws an OSError with the appropriate error code instead of an EOFError. +diff --git a/Modules/_ssl.c b/Modules/_ssl.c +index 0b8cf0b6df..42a4c95890 100644 +--- a/Modules/_ssl.c ++++ b/Modules/_ssl.c +@@ -573,7 +573,7 @@ PySSL_ChainExceptions(PySSLSocket *sslsock) { + } + + static PyObject * +-PySSL_SetError(PySSLSocket *sslsock, int ret, const char *filename, int lineno) ++PySSL_SetError(PySSLSocket *sslsock, const char *filename, int lineno) + { + PyObject *type; + char *errstr = NULL; +@@ -586,7 +586,6 @@ PySSL_SetError(PySSLSocket *sslsock, int ret, const char *filename, int lineno) + _sslmodulestate *state = get_state_sock(sslsock); + type = state->PySSLErrorObject; + +- assert(ret <= 0); + e = ERR_peek_last_error(); + + if (sslsock->ssl != NULL) { +@@ -619,32 +618,21 @@ PySSL_SetError(PySSLSocket *sslsock, int ret, const char *filename, int lineno) + case SSL_ERROR_SYSCALL: + { + if (e == 0) { +- PySocketSockObject *s = GET_SOCKET(sslsock); +- if (ret == 0 || (((PyObject *)s) == Py_None)) { ++ /* underlying BIO reported an I/O error */ ++ ERR_clear_error(); ++#ifdef MS_WINDOWS ++ if (err.ws) { ++ return PyErr_SetFromWindowsErr(err.ws); ++ } ++#endif ++ if (err.c) { ++ errno = err.c; ++ return PyErr_SetFromErrno(PyExc_OSError); ++ } ++ else { + p = PY_SSL_ERROR_EOF; + type = state->PySSLEOFErrorObject; + errstr = "EOF occurred in violation of protocol"; +- } else if (s && ret == -1) { +- /* underlying BIO reported an I/O error */ +- ERR_clear_error(); +-#ifdef MS_WINDOWS +- if (err.ws) { +- return PyErr_SetFromWindowsErr(err.ws); +- } +-#endif +- if (err.c) { +- errno = err.c; +- return PyErr_SetFromErrno(PyExc_OSError); +- } +- else { +- p = PY_SSL_ERROR_EOF; +- type = state->PySSLEOFErrorObject; +- errstr = "EOF occurred in violation of protocol"; +- } +- } else { /* possible? */ +- p = PY_SSL_ERROR_SYSCALL; +- type = state->PySSLSyscallErrorObject; +- errstr = "Some I/O error occurred"; + } + } else { + if (ERR_GET_LIB(e) == ERR_LIB_SSL && +@@ -1007,7 +995,7 @@ _ssl__SSLSocket_do_handshake_impl(PySSLSocket *self) + err.ssl == SSL_ERROR_WANT_WRITE); + Py_XDECREF(sock); + if (ret < 1) +- return PySSL_SetError(self, ret, __FILE__, __LINE__); ++ return PySSL_SetError(self, __FILE__, __LINE__); + if (PySSL_ChainExceptions(self) < 0) + return NULL; + Py_RETURN_NONE; +@@ -2424,7 +2412,7 @@ _ssl__SSLSocket_write_impl(PySSLSocket *self, Py_buffer *b) + + Py_XDECREF(sock); + if (retval == 0) +- return PySSL_SetError(self, retval, __FILE__, __LINE__); ++ return PySSL_SetError(self, __FILE__, __LINE__); + if (PySSL_ChainExceptions(self) < 0) + return NULL; + return PyLong_FromSize_t(count); +@@ -2454,7 +2442,7 @@ _ssl__SSLSocket_pending_impl(PySSLSocket *self) + self->err = err; + + if (count < 0) +- return PySSL_SetError(self, count, __FILE__, __LINE__); ++ return PySSL_SetError(self, __FILE__, __LINE__); + else + return PyLong_FromLong(count); + } +@@ -2577,7 +2565,7 @@ _ssl__SSLSocket_read_impl(PySSLSocket *self, Py_ssize_t len, + err.ssl == SSL_ERROR_WANT_WRITE); + + if (retval == 0) { +- PySSL_SetError(self, retval, __FILE__, __LINE__); ++ PySSL_SetError(self, __FILE__, __LINE__); + goto error; + } + if (self->exc != NULL) +@@ -2703,7 +2691,7 @@ _ssl__SSLSocket_shutdown_impl(PySSLSocket *self) + } + if (ret < 0) { + Py_XDECREF(sock); +- PySSL_SetError(self, ret, __FILE__, __LINE__); ++ PySSL_SetError(self, __FILE__, __LINE__); + return NULL; + } + if (self->exc != NULL) diff --git a/SOURCES/00465-tarfile-cves.patch b/SOURCES/00465-tarfile-cves.patch deleted file mode 100644 index 81fae8b..0000000 --- a/SOURCES/00465-tarfile-cves.patch +++ /dev/null @@ -1,1950 +0,0 @@ -From 0f633408d672dba729482803d872a00bbf47d3b0 Mon Sep 17 00:00:00 2001 -From: "T. Wouters" -Date: Tue, 3 Jun 2025 16:00:21 +0200 -Subject: [PATCH] gh-135034: Normalize link targets in tarfile, add - `os.path.realpath(strict='allow_missing')` (GH-135037) (GH-135066) -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 -Signed-off-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.12.rst | 34 ++ - Lib/genericpath.py | 11 +- - Lib/ntpath.py | 37 ++- - Lib/posixpath.py | 15 +- - Lib/tarfile.py | 161 +++++++-- - Lib/test/test_ntpath.py | 284 ++++++++++++++-- - Lib/test/test_posixpath.py | 289 +++++++++++++--- - Lib/test/test_tarfile.py | 313 ++++++++++++++++-- - ...-06-02-11-32-23.gh-issue-135034.RLGjbp.rst | 6 + - 11 files changed, 1064 insertions(+), 139 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 51e8908..f5e684f 100644 ---- a/Doc/library/os.path.rst -+++ b/Doc/library/os.path.rst -@@ -377,10 +377,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 -@@ -399,6 +415,15 @@ the :mod:`glob` module.) - .. versionchanged:: 3.10 - 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 0352cdd..a2403b7 100644 ---- a/Doc/library/tarfile.rst -+++ b/Doc/library/tarfile.rst -@@ -249,6 +249,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: - -@@ -1039,6 +1048,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. - -@@ -1067,6 +1082,10 @@ reused in custom filters: - - Return the modified ``TarInfo`` member. - -+ .. versionchanged:: next -+ -+ Link targets are now normalized. -+ - - .. _tarfile-extraction-refuse: - -@@ -1093,6 +1112,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.12.rst b/Doc/whatsnew/3.12.rst -index add932b..605ea0b 100644 ---- a/Doc/whatsnew/3.12.rst -+++ b/Doc/whatsnew/3.12.rst -@@ -2312,3 +2312,37 @@ sys - * The previously undocumented special function :func:`sys.getobjects`, - which only exists in specialized builds of Python, may now return objects - from other interpreters than the one it's called in. -+ -+ -+Notable changes in 3.12.10 -+========================== -+ -+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 1bd5b38..233f7a3 100644 ---- a/Lib/genericpath.py -+++ b/Lib/genericpath.py -@@ -8,7 +8,7 @@ import stat - - __all__ = ['commonprefix', 'exists', 'getatime', 'getctime', 'getmtime', - 'getsize', 'isdir', 'isfile', 'islink', 'samefile', 'sameopenfile', -- 'samestat'] -+ 'samestat', 'ALLOW_MISSING'] - - - # Does a path exist? -@@ -165,3 +165,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 c05e965..1bef630 100644 ---- a/Lib/ntpath.py -+++ b/Lib/ntpath.py -@@ -30,7 +30,8 @@ __all__ = ["normcase","isabs","join","splitdrive","splitroot","split","splitext" - "ismount", "expanduser","expandvars","normpath","abspath", - "curdir","pardir","sep","pathsep","defpath","altsep", - "extsep","devnull","realpath","supports_unicode_filenames","relpath", -- "samefile", "sameopenfile", "samestat", "commonpath", "isjunction"] -+ "samefile", "sameopenfile", "samestat", "commonpath", "isjunction", -+ "ALLOW_MISSING"] - - def _get_bothseps(path): - if isinstance(path, bytes): -@@ -609,9 +610,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 -@@ -644,7 +646,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 -@@ -653,7 +655,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 -@@ -680,17 +682,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) -@@ -721,6 +724,15 @@ 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: -@@ -728,17 +740,16 @@ else: - initial_winerror = 0 - except ValueError as ex: - # gh-106242: Raised for embedded null characters -- # In strict mode, we convert into an OSError. -+ # In strict modes, we convert into an OSError. - # Non-strict mode returns the path as-is, since we've already - # made it absolute. - if strict: - raise OSError(str(ex)) from None - path = normpath(path) -- 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 f1e4237..90a6f54 100644 ---- a/Lib/posixpath.py -+++ b/Lib/posixpath.py -@@ -35,7 +35,7 @@ __all__ = ["normcase","isabs","join","splitdrive","splitroot","split","splitext" - "samefile","sameopenfile","samestat", - "curdir","pardir","sep","pathsep","defpath","altsep","extsep", - "devnull","realpath","supports_unicode_filenames","relpath", -- "commonpath", "isjunction"] -+ "commonpath", "isjunction", "ALLOW_MISSING"] - - - def _get_sep(path): -@@ -438,6 +438,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:] -@@ -460,9 +469,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 a04e15b..7299daa 100755 ---- a/Lib/tarfile.py -+++ b/Lib/tarfile.py -@@ -759,10 +759,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)): -@@ -772,7 +784,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) -@@ -810,6 +823,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), -@@ -817,7 +833,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 -@@ -2328,30 +2345,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, -@@ -2367,41 +2412,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: -@@ -2459,9 +2519,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 -@@ -2490,7 +2554,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: -@@ -2573,10 +2640,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(): -@@ -2584,18 +2659,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") from None -+ 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") from None -+ 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 4924db9..93d7011 100644 ---- a/Lib/test/test_ntpath.py -+++ b/Lib/test/test_ntpath.py -@@ -2,9 +2,11 @@ import inspect - import ntpath - import os - import string -+import subprocess - import sys - import unittest - import warnings -+from ntpath import ALLOW_MISSING - from test.support import cpython_only, os_helper - from test.support import TestFailed, is_emscripten - from test.support.os_helper import FakePath -@@ -76,6 +78,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): -@@ -363,6 +386,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)) -@@ -375,28 +419,59 @@ 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] + '\\') -+ - @os_helper.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(os_helper.TESTFN) - open(ABSTFN, "wb").close() - self.addCleanup(os_helper.unlink, ABSTFN) - self.addCleanup(os_helper.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)) - - # gh-88013: call ntpath.realpath with binary drive name may raise a - # TypeError. The drive should not exist to reproduce the bug. - drives = {f"{c}:\\" for c in string.ascii_uppercase} - set(os.listdrives()) - d = drives.pop().encode() -- self.assertEqual(ntpath.realpath(d), d) -+ self.assertEqual(ntpath.realpath(d, strict=False), d) - - # gh-106242: Embedded nulls and non-strict fallback to abspath -- self.assertEqual(ABSTFN + "\0spam", -- ntpath.realpath(os_helper.TESTFN + "\0spam", strict=False)) -+ if kwargs: -+ with self.assertRaises(OSError): -+ ntpath.realpath(os_helper.TESTFN + "\0spam", -+ **kwargs) -+ else: -+ self.assertEqual(ABSTFN + "\0spam", -+ ntpath.realpath(os_helper.TESTFN + "\0spam", **kwargs)) - - @os_helper.skip_unless_symlink - @unittest.skipUnless(HAVE_GETFINALPATHNAME, 'need _getfinalpathname') -@@ -408,19 +483,77 @@ class TestNtpath(NtpathTestCase): - self.addCleanup(os_helper.unlink, ABSTFN) - self.assertRaises(FileNotFoundError, ntpath.realpath, ABSTFN, strict=True) - self.assertRaises(FileNotFoundError, ntpath.realpath, ABSTFN + "2", strict=True) -+ -+ @unittest.skipUnless(HAVE_GETFINALPATHNAME, 'need _getfinalpathname') -+ def test_realpath_invalid_paths(self): -+ realpath = ntpath.realpath -+ ABSTFN = ntpath.abspath(os_helper.TESTFN) -+ ABSTFNb = os.fsencode(ABSTFN) -+ path = ABSTFN + '\x00' -+ # gh-106242: Embedded nulls and non-strict fallback to abspath -+ self.assertEqual(realpath(path, strict=False), path) - # gh-106242: Embedded nulls should raise OSError (not ValueError) -- self.assertRaises(OSError, ntpath.realpath, ABSTFN + "\0spam", strict=True) -+ self.assertRaises(OSError, ntpath.realpath, path, strict=True) -+ self.assertRaises(OSError, ntpath.realpath, path, strict=ALLOW_MISSING) -+ path = ABSTFNb + b'\x00' -+ self.assertEqual(realpath(path, strict=False), path) -+ self.assertRaises(OSError, ntpath.realpath, path, strict=True) -+ self.assertRaises(OSError, ntpath.realpath, path, strict=ALLOW_MISSING) -+ path = ABSTFN + '\\nonexistent\\x\x00' -+ self.assertEqual(realpath(path, strict=False), path) -+ self.assertRaises(OSError, ntpath.realpath, path, strict=True) -+ self.assertRaises(OSError, ntpath.realpath, path, strict=ALLOW_MISSING) -+ path = ABSTFNb + b'\\nonexistent\\x\x00' -+ self.assertEqual(realpath(path, strict=False), path) -+ self.assertRaises(OSError, ntpath.realpath, path, strict=True) -+ self.assertRaises(OSError, ntpath.realpath, path, strict=ALLOW_MISSING) -+ path = ABSTFN + '\x00\\..' -+ self.assertEqual(realpath(path, strict=False), os.getcwd()) -+ self.assertEqual(realpath(path, strict=True), os.getcwd()) -+ self.assertEqual(realpath(path, strict=ALLOW_MISSING), os.getcwd()) -+ path = ABSTFNb + b'\x00\\..' -+ self.assertEqual(realpath(path, strict=False), os.getcwdb()) -+ self.assertEqual(realpath(path, strict=True), os.getcwdb()) -+ self.assertEqual(realpath(path, strict=ALLOW_MISSING), os.getcwdb()) -+ path = ABSTFN + '\\nonexistent\\x\x00\\..' -+ self.assertEqual(realpath(path, strict=False), ABSTFN + '\\nonexistent') -+ self.assertRaises(OSError, ntpath.realpath, path, strict=True) -+ self.assertEqual(realpath(path, strict=ALLOW_MISSING), ABSTFN + '\\nonexistent') -+ path = ABSTFNb + b'\\nonexistent\\x\x00\\..' -+ self.assertEqual(realpath(path, strict=False), ABSTFNb + b'\\nonexistent') -+ self.assertRaises(OSError, ntpath.realpath, path, strict=True) -+ self.assertEqual(realpath(path, strict=ALLOW_MISSING), ABSTFNb + b'\\nonexistent') -+ -+ @unittest.skipUnless(HAVE_GETFINALPATHNAME, 'need _getfinalpathname') -+ @_parameterize({}, {'strict': True}, {'strict': ALLOW_MISSING}) -+ def test_realpath_invalid_unicode_paths(self, kwargs): -+ realpath = ntpath.realpath -+ ABSTFN = ntpath.abspath(os_helper.TESTFN) -+ ABSTFNb = os.fsencode(ABSTFN) -+ path = ABSTFNb + b'\xff' -+ self.assertRaises(UnicodeDecodeError, ntpath.realpath, path, **kwargs) -+ self.assertRaises(UnicodeDecodeError, ntpath.realpath, path, **kwargs) -+ path = ABSTFNb + b'\\nonexistent\\\xff' -+ self.assertRaises(UnicodeDecodeError, ntpath.realpath, path, **kwargs) -+ self.assertRaises(UnicodeDecodeError, ntpath.realpath, path, **kwargs) -+ path = ABSTFNb + b'\xff\\..' -+ self.assertRaises(UnicodeDecodeError, ntpath.realpath, path, **kwargs) -+ self.assertRaises(UnicodeDecodeError, ntpath.realpath, path, **kwargs) -+ path = ABSTFNb + b'\\nonexistent\\\xff\\..' -+ self.assertRaises(UnicodeDecodeError, ntpath.realpath, path, **kwargs) -+ self.assertRaises(UnicodeDecodeError, ntpath.realpath, path, **kwargs) - - @os_helper.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(os_helper.TESTFN) - open(ABSTFN, "wb").close() - self.addCleanup(os_helper.unlink, ABSTFN) - self.addCleanup(os_helper.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) - - @os_helper.skip_unless_symlink - @unittest.skipUnless(HAVE_GETFINALPATHNAME, 'need _getfinalpathname') -@@ -572,7 +705,62 @@ class TestNtpath(NtpathTestCase): - - @os_helper.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(os_helper.TESTFN) -+ self.addCleanup(os_helper.unlink, ABSTFN) -+ self.addCleanup(os_helper.unlink, ABSTFN + "1") -+ self.addCleanup(os_helper.unlink, ABSTFN + "2") -+ self.addCleanup(os_helper.unlink, ABSTFN + "y") -+ self.addCleanup(os_helper.unlink, ABSTFN + "c") -+ self.addCleanup(os_helper.unlink, ABSTFN + "a") -+ self.addCleanup(os_helper.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) -+ -+ @os_helper.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(os_helper.TESTFN) - self.addCleanup(os_helper.unlink, ABSTFN + "3") - self.addCleanup(os_helper.unlink, "\\\\?\\" + ABSTFN + "3.") -@@ -587,9 +775,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 -@@ -599,14 +787,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') -@@ -630,12 +821,65 @@ class TestNtpath(NtpathTestCase): - - self.assertPathEqual(test_file_long, ntpath.realpath(test_file_short)) - -- with os_helper.change_cwd(test_dir_long): -- self.assertPathEqual(test_file_long, ntpath.realpath("file.txt")) -- with os_helper.change_cwd(test_dir_long.lower()): -- self.assertPathEqual(test_file_long, ntpath.realpath("file.txt")) -- with os_helper.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 os_helper.change_cwd(test_dir_long): -+ self.assertPathEqual( -+ test_file_long, -+ ntpath.realpath("file.txt", **kwargs)) -+ with os_helper.change_cwd(test_dir_long.lower()): -+ self.assertPathEqual( -+ test_file_long, -+ ntpath.realpath("file.txt", **kwargs)) -+ with os_helper.change_cwd(test_dir_short): -+ self.assertPathEqual( -+ test_file_long, -+ ntpath.realpath("file.txt", **kwargs)) -+ -+ @unittest.skipUnless(HAVE_GETFINALPATHNAME, 'need _getfinalpathname') -+ def test_realpath_permission(self): -+ # Test whether python can resolve the real filename of a -+ # shortened file name even if it does not have permission to access it. -+ ABSTFN = ntpath.realpath(os_helper.TESTFN) -+ -+ os_helper.unlink(ABSTFN) -+ os_helper.rmtree(ABSTFN) -+ os.mkdir(ABSTFN) -+ self.addCleanup(os_helper.rmtree, ABSTFN) -+ -+ test_file = ntpath.join(ABSTFN, "LongFileName123.txt") -+ test_file_short = ntpath.join(ABSTFN, "LONGFI~1.TXT") -+ -+ with open(test_file, "wb") as f: -+ f.write(b"content") -+ # Automatic generation of short names may be disabled on -+ # NTFS volumes for the sake of performance. -+ # They're not supported at all on ReFS and exFAT. -+ p = subprocess.run( -+ # Try to set the short name manually. -+ ['fsutil.exe', 'file', 'setShortName', test_file, 'LONGFI~1.TXT'], -+ creationflags=subprocess.DETACHED_PROCESS -+ ) -+ -+ if p.returncode: -+ raise unittest.SkipTest('failed to set short name') -+ -+ try: -+ self.assertPathEqual(test_file, ntpath.realpath(test_file_short)) -+ except AssertionError: -+ raise unittest.SkipTest('the filesystem seems to lack support for short filenames') -+ -+ # Deny the right to [S]YNCHRONIZE on the file to -+ # force nt._getfinalpathname to fail with ERROR_ACCESS_DENIED. -+ p = subprocess.run( -+ ['icacls.exe', test_file, '/deny', '*S-1-5-32-545:(S)'], -+ creationflags=subprocess.DETACHED_PROCESS -+ ) -+ -+ if p.returncode: -+ raise unittest.SkipTest('failed to deny access to the test file') -+ -+ self.assertPathEqual(test_file, ntpath.realpath(test_file_short)) - - def test_expandvars(self): - with os_helper.EnvironmentVarGuard() as env: -diff --git a/Lib/test/test_posixpath.py b/Lib/test/test_posixpath.py -index cc4fd2f..7ae54d2 100644 ---- a/Lib/test/test_posixpath.py -+++ b/Lib/test/test_posixpath.py -@@ -3,7 +3,9 @@ import os - import posixpath - import sys - 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 test_genericpath - from test.support import import_helper - from test.support import cpython_only, os_helper -@@ -37,6 +39,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): -@@ -425,32 +447,35 @@ class PosixPathTest(unittest.TestCase): - self.assertEqual(result, expected) - - @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'/') - - @os_helper.skip_unless_symlink - @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: - os_helper.unlink(ABSTFN) - -@@ -466,15 +491,122 @@ class PosixPathTest(unittest.TestCase): - finally: - os_helper.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) -+ if support.is_wasi: -+ self.assertRaises(OSError, realpath, path, strict=True) -+ self.assertRaises(OSError, realpath, path, strict=ALLOW_MISSING) -+ else: -+ 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) -+ if support.is_wasi: -+ self.assertRaises(OSError, realpath, path, strict=True) -+ self.assertRaises(OSError, realpath, path, strict=ALLOW_MISSING) -+ else: -+ self.assertRaises(FileNotFoundError, realpath, path, strict=True) -+ - @os_helper.skip_unless_symlink - @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: - os_helper.unlink(ABSTFN) - -+ @os_helper.skip_unless_symlink -+ @skip_if_ABSTFN_contains_backslash -+ @_parameterize({}, {'strict': ALLOW_MISSING}) -+ def test_realpath_missing_pardir(self, kwargs): -+ try: -+ os.symlink(os_helper.TESTFN + "1", os_helper.TESTFN) -+ self.assertEqual( -+ realpath("nonexistent/../" + os_helper.TESTFN, **kwargs), ABSTFN + "1") -+ finally: -+ os_helper.unlink(os_helper.TESTFN) -+ - @os_helper.skip_unless_symlink - @skip_if_ABSTFN_contains_backslash - def test_realpath_symlink_loops(self): -@@ -518,37 +650,38 @@ class PosixPathTest(unittest.TestCase): - - @os_helper.skip_unless_symlink - @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 os_helper.change_cwd(dirname(ABSTFN)): -- self.assertRaises(OSError, realpath, basename(ABSTFN), strict=True) -+ self.assertRaises(OSError, realpath, basename(ABSTFN), **kwargs) - finally: - os_helper.unlink(ABSTFN) - os_helper.unlink(ABSTFN+"1") -@@ -559,13 +692,14 @@ class PosixPathTest(unittest.TestCase): - - @os_helper.skip_unless_symlink - @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: - os_helper.unlink(ABSTFN + '/self') - os_helper.unlink(ABSTFN + '/link') -@@ -573,14 +707,15 @@ class PosixPathTest(unittest.TestCase): - - @os_helper.skip_unless_symlink - @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 os_helper.change_cwd(ABSTFN): -@@ -592,7 +727,8 @@ class PosixPathTest(unittest.TestCase): - - @os_helper.skip_unless_symlink - @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 -@@ -603,7 +739,8 @@ class PosixPathTest(unittest.TestCase): - os.symlink(ABSTFN + "/y", ABSTFN + "/k") - - with os_helper.change_cwd(ABSTFN + "/k"): -- self.assertEqual(realpath("a"), ABSTFN + "/y/a") -+ self.assertEqual(realpath("a", **kwargs), -+ ABSTFN + "/y/a") - finally: - os_helper.unlink(ABSTFN + "/k") - safe_rmdir(ABSTFN + "/y") -@@ -611,7 +748,8 @@ class PosixPathTest(unittest.TestCase): - - @os_helper.skip_unless_symlink - @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: -@@ -626,10 +764,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 os_helper.change_cwd(dirname(ABSTFN)): -- self.assertEqual(realpath(basename(ABSTFN) + "/link-y/.."), -+ self.assertEqual(realpath(basename(ABSTFN) + "/link-y/..", **kwargs), - ABSTFN + "/k") - finally: - os_helper.unlink(ABSTFN + "/link-y") -@@ -639,7 +777,8 @@ class PosixPathTest(unittest.TestCase): - - @os_helper.skip_unless_symlink - @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. - -@@ -649,13 +788,70 @@ class PosixPathTest(unittest.TestCase): - os.symlink(ABSTFN, ABSTFN + "link") - with os_helper.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: - os_helper.unlink(ABSTFN + "link") - safe_rmdir(ABSTFN + "/k") - safe_rmdir(ABSTFN) - -+ @os_helper.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 -+ @os_helper.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: -@@ -835,9 +1031,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 dfe03d2..5d9c88e 100644 ---- a/Lib/test/test_tarfile.py -+++ b/Lib/test/test_tarfile.py -@@ -2647,7 +2647,8 @@ class MiscTest(unittest.TestCase): - 'PAX_NUMBER_FIELDS', 'stn', 'nts', 'nti', 'itn', 'calc_chksums', - 'copyfileobj', 'filemode', 'EmptyHeaderError', - 'TruncatedHeaderError', 'EOFHeaderError', 'InvalidHeaderError', -- 'SubsequentHeaderError', 'ExFileObject', 'main'} -+ 'SubsequentHeaderError', 'ExFileObject', 'main', -+ "LinkFallbackError"} - support.check__all__(self, tarfile, not_exported=not_exported) - - def test_useful_error_message_when_modules_missing(self): -@@ -2662,6 +2663,31 @@ class MiscTest(unittest.TestCase): - str(excinfo.exception), - ) - -+ @unittest.skipUnless(os_helper.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: -+ os_helper.rmtree(outside_tree_dir) -+ os_helper.rmtree(tempdir) -+ - - class CommandLineTest(unittest.TestCase): - -@@ -3222,6 +3248,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 -@@ -3448,12 +3478,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: -@@ -3467,7 +3513,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) -@@ -3501,7 +3547,7 @@ class TestExtractionFilters(unittest.TestCase): - destdir = outerdir / 'dest' - - @contextmanager -- def check_context(self, tar, filter): -+ def check_context(self, tar, filter, *, check_flag=True): - """Extracts `tar` to `self.destdir` and allows checking the result - - If an error occurs, it must be checked using `expect_exception` -@@ -3510,27 +3556,40 @@ 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. - """ - with os_helper.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: - 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 -@@ -3549,26 +3608,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: -@@ -3653,6 +3731,80 @@ class TestExtractionFilters(unittest.TestCase): - with self.check_context(arc.open(), 'data'): - self.expect_file('parent/evil') - -+ @symlink_test -+ @os_helper.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)): -+ 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_any_tree(component) -+ 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) -+ - @symlink_test - def test_parent_symlink2(self): - # Test interplaying symlinks -@@ -3875,8 +4027,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): -@@ -3892,6 +4044,129 @@ class TestExtractionFilters(unittest.TestCase): - self.expect_file('linkdir/symlink', size=3) - self.expect_file('symlink2', size=3) - -+ @symlink_test -+ 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 os_helper.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 os_helper.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') -+ -+ @symlink_test -+ 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 os_helper.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 -+ -+ @symlink_test -+ 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 os_helper.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) -@@ -4005,7 +4280,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) -@@ -4014,7 +4289,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, '') -@@ -4144,13 +4419,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) -@@ -4161,7 +4436,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..08a0087 ---- /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.12.11.tar.xz.asc b/SOURCES/Python-3.12.11.tar.xz.asc new file mode 100644 index 0000000..6853102 --- /dev/null +++ b/SOURCES/Python-3.12.11.tar.xz.asc @@ -0,0 +1,18 @@ +-----BEGIN PGP SIGNATURE----- + +iQKTBAABCgB9FiEEcWlgX2LHUTVtBUomqCHmgOX6YwUFAmg/MbpfFIAAAAAALgAo +aXNzdWVyLWZwckBub3RhdGlvbnMub3BlbnBncC5maWZ0aGhvcnNlbWFuLm5ldDcx +Njk2MDVGNjJDNzUxMzU2RDA1NEEyNkE4MjFFNjgwRTVGQTYzMDUACgkQqCHmgOX6 +YwXySQ/7Ba9qlnTLmxqTCO8C7Gf545WNMBL2Ep6JZPgjOcgNk9e1QdAnNV5OOtGm +gW5nNPSTNNcIcPn058GuI24D4RpTQCJfMbMLsfYgvio0E7ij1gC19PsJHb6ejtCS +H2kK237Y1kuqRUdbTZssFDoAR4R9+UCaDuo4XdW+UKQk2GgdNQDMWLKmWF/Xk6Ob +/LihMXj27mDU9nXVdWR55sJzTFzfGB015vmORvcpuctkf1lZ4AfVFMgGw1CgjRjF +kjrOkrDErjDUQ8BIhMh90deiTpigfg7cg1HBDI6GRzklFg6cMfIdfvmfM0MfamX3 +Tow08TGBzmYXWgrqjYXW6JknKhBGOrjXMB7/yNDk9bJVLcOJaLbOmbcG0WRQF/Py +DMOCvr09l0yt5KFYpdKrDvyCuKYfpX33B4C60kU9JzmfXGyQ6LDTPXapZooJ+8Fg +GRTUsc0YWXoaDVCcxMIdiG+jEMQkjWVwW7E/nC/d7WT5L9KPoYFA1sZ834kKq3jr +NmZynbBnKH7m7L+u6HP6B+pa84FKEME69osAXZk0HJOIHB+SOX3E6BXRo6IV8Q/K +J6f5Ja26gJ7KXcUxTgkTkYh7tz0bhb+WeL3j6N/BC0eK7ZVsKRZ/3WnntGsG5B2m +FjVOYKolfkF4tf63SjdFuudgaKGCaDK1PvfwIr7k0oozxrB2ZEA= +=SYH/ +-----END PGP SIGNATURE----- diff --git a/SOURCES/Python-3.12.9.tar.xz.asc b/SOURCES/Python-3.12.9.tar.xz.asc deleted file mode 100644 index 75a8cde..0000000 --- a/SOURCES/Python-3.12.9.tar.xz.asc +++ /dev/null @@ -1,18 +0,0 @@ ------BEGIN PGP SIGNATURE----- - -iQKTBAABCgB9FiEEcWlgX2LHUTVtBUomqCHmgOX6YwUFAmeiX7JfFIAAAAAALgAo -aXNzdWVyLWZwckBub3RhdGlvbnMub3BlbnBncC5maWZ0aGhvcnNlbWFuLm5ldDcx -Njk2MDVGNjJDNzUxMzU2RDA1NEEyNkE4MjFFNjgwRTVGQTYzMDUACgkQqCHmgOX6 -YwXTqw//VlGJA5CRDfljMwN9BmG2hdXB1B7Lj0PssuAo4A/lH99gb4DRVDS9LNjr -99WdH/fQQovx6rTbtyJnN8Vh7SSduBi/vOc5n5VOXZB0buqR0l+0wu4m43Slu6xP -fXO349Hr6585lemU8x54TrP756rSVUhy3T+krUuNDL9W1Wrp2yDCpt4tUoEhNXGw -DoYS8MrK/ygLNV/7p2DeMWOHNdbjKNH6rfzl60IAwAp7oANcyoj6Pho960bbeUDo -tb47Pw0WWZv3EuITP6bPa8+Z6dj096cFL3AQJ3ap16OduwiaOsGhqTfe4+kbp6ut -Gp/1HeIHzPbEV0E5K78RWHuzBYgU1oPGiMjlp7WkA7bP2OSTF7nM4EBkiiihk2qx -3d5VF9wpVRJ4AuR/aWcWcMnvD2ziSWfzZM3Z3VLnTaWYpuRkQp8TTiFr1vHqxMYm -p/8AozzBJMfOS6u/Q0WNAdk6x3VB0DXnTAETXQVIrex4DXqX/3WSMWK5/x/OyCh9 -ytdreIQYbv1KvlNQJkgpPb7jlUSXp8t9fHCXt4hszhJgtjwIj/+CuSeAgX0bhopV -XsqOBseDNhATg38mhwBVaeFKGRpxsKdpxcdqSEGKuhXtEI/hJmkpZGw49gy3xWxB -KlgRgKjCPw+BGAIVV9qvdtJzam8a09SKVcslqgF619q0byQoBmo= -=1TbP ------END PGP SIGNATURE----- diff --git a/SPECS/python3.12.spec b/SPECS/python3.12.spec index 45e0f9b..2580196 100644 --- a/SPECS/python3.12.spec +++ b/SPECS/python3.12.spec @@ -16,11 +16,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}.9 +%global general_version %{pybasever}.11 #global prerel ... %global upstream_version %{general_version}%{?prerel} Version: %{general_version}%{?prerel:~%{prerel}} -Release: 1%{?dist}.2 +Release: 2%{?dist} License: Python-2.0.1 @@ -66,28 +66,28 @@ License: Python-2.0.1 # If the rpmwheels condition is disabled, we use the bundled wheel packages # from Python with the versions below. # This needs to be manually updated when we update Python. -%global pip_version 24.3.1 +%global pip_version 25.0.1 %global setuptools_version 67.6.1 %global wheel_version 0.40.0 # All of those also include a list of indirect bundled libs: # pip # $ %%{_rpmconfigdir}/pythonbundles.py <(unzip -p Lib/ensurepip/_bundled/pip-*.whl pip/_vendor/vendor.txt) %global pip_bundled_provides %{expand: -Provides: bundled(python3dist(cachecontrol)) = 0.14 +Provides: bundled(python3dist(cachecontrol)) = 0.14.1 Provides: bundled(python3dist(certifi)) = 2024.8.30 Provides: bundled(python3dist(distlib)) = 0.3.9 Provides: bundled(python3dist(distro)) = 1.9 -Provides: bundled(python3dist(idna)) = 3.7 -Provides: bundled(python3dist(msgpack)) = 1.0.8 -Provides: bundled(python3dist(packaging)) = 24.1 -Provides: bundled(python3dist(platformdirs)) = 4.2.2 +Provides: bundled(python3dist(idna)) = 3.10 +Provides: bundled(python3dist(msgpack)) = 1.1 +Provides: bundled(python3dist(packaging)) = 24.2 +Provides: bundled(python3dist(platformdirs)) = 4.3.6 Provides: bundled(python3dist(pygments)) = 2.18 -Provides: bundled(python3dist(pyproject-hooks)) = 1 +Provides: bundled(python3dist(pyproject-hooks)) = 1.2 Provides: bundled(python3dist(requests)) = 2.32.3 Provides: bundled(python3dist(resolvelib)) = 1.0.1 -Provides: bundled(python3dist(rich)) = 13.7.1 +Provides: bundled(python3dist(rich)) = 13.9.4 Provides: bundled(python3dist(setuptools)) = 70.3 -Provides: bundled(python3dist(tomli)) = 2.0.1 +Provides: bundled(python3dist(tomli)) = 2.2.1 Provides: bundled(python3dist(truststore)) = 0.10 Provides: bundled(python3dist(typing-extensions)) = 4.12.2 Provides: bundled(python3dist(urllib3)) = 1.26.20 @@ -279,6 +279,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) @@ -385,12 +386,28 @@ Patch397: 00397-tarfile-filter.patch # CVE-2023-52425. Future versions of Expat may be more reactive. Patch422: 00422-fix-tests-for-xmlpullparser-with-expat-2-6-0.patch -# 00465 # -# Security fixes for: -# CVE-2025-4517, CVE-2025-4330, CVE-2025-4138, CVE-2024-12718 and CVE-2025-4435 in the tarfile module. +# 00459 # 906f6692bd85034012c9554f2434627ccfc04c67 +# Apply Intel Control-flow Technology for x86-64 # -# Resolved upstream: https://github.com/python/cpython/pull/135066 -Patch465: 00465-tarfile-cves.patch +# Required for mitigation against return-oriented programming (ROP) and Call or Jump Oriented Programming (COP/JOP) attacks +# +# Proposed upstream: https://github.com/python/cpython/pull/128606 +# +# See also: https://sourceware.org/annobin/annobin.html/Test-cf-protection.html +Patch459: 00459-apply-intel-control-flow-technology-for-x86-64.patch + +# 00462 # 5324dc5f57e0068f7e4f7b2f20006e88ff5f4e47 +# Fix PySSL_SetError handling SSL_ERROR_SYSCALL +# +# Python 3.10 changed from using SSL_write() and SSL_read() to SSL_write_ex() and +# SSL_read_ex(), but did not update handling of the return value. +# +# Change error handling so that the return value is not examined. +# OSError (not EOF) is now returned when retval is 0. +# +# This resolves the issue of failing tests when a system is +# stressed on OpenSSL 3.5. +Patch462: 00462-fix-pyssl_seterror-handling-ssl_error_syscall.patch # 00467 # # CVE-2025-8194 @@ -1714,13 +1731,27 @@ CheckPython optimized # ====================================================== %changelog -* Thu Aug 14 2025 Lumír Balhar - 3.12.9-1.2 +* Thu Aug 14 2025 Lumír Balhar - 3.12.11-2 - Security fix for CVE-2025-8194 -Resolves: RHEL-106370 +Resolves: RHEL-106369 -* Fri Jun 20 2025 Charalampos Stratakis - 3.12.9-1.1 +* Wed Jun 04 2025 Tomáš Hrnčiar - 3.12.11-1 +- Update to 3.12.11 - Security fixes for CVE-2025-4517, CVE-2025-4330, CVE-2025-4138, CVE-2024-12718, CVE-2025-4435 -- Resolves: RHEL-98058, RHEL-98020, RHEL-97809, RHEL-98184, RHEL-98211 +Resolves: RHEL-98057, RHEL-98019, RHEL-97811, RHEL-98183, RHEL-98210 + +* Fri May 09 2025 Charalampos Stratakis - 3.12.10-3 +- Fix PySSL_SetError handling SSL_ERROR_SYSCALL +- This fixes random flakiness of test_ssl on stressed machines +Resolves: RHEL-88897 + +* Tue Apr 22 2025 Charalampos Stratakis - 3.12.10-2 +- Apply Intel's CET for mitigation against control-flow hijacking attacks +Resolves: RHEL-88326 + +* Wed Apr 09 2025 Miro Hrončok - 3.12.10-1 +- Update to 3.12.10 +Resolves: RHEL-86887 * Tue Feb 04 2025 Charalampos Stratakis - 3.12.9-1 - Update to 3.12.9