From 6cbf255ebedab9946d9c52affa77a3e0b58e164c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miro=20Hron=C4=8Dok?= Date: Wed, 23 Apr 2025 11:44:23 +0000 Subject: [PATCH 01/10] Add RPM Provides for python3.6-libs, python3.6-devel, python3.6-idle, python3.6-tkinter, python3.6-test This is a first step to switch the flatpackage bcond in order to maintain the same structure as Python 3.10+. --- python3.6.spec | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/python3.6.spec b/python3.6.spec index 35c5489..a57ef96 100644 --- a/python3.6.spec +++ b/python3.6.spec @@ -17,7 +17,7 @@ URL: https://www.python.org/ #global prerel ... %global upstream_version %{general_version}%{?prerel} Version: %{general_version}%{?prerel:~%{prerel}} -Release: 45%{?dist} +Release: 46%{?dist} # Python is Python # pip MIT is and bundles: # appdirs: MIT @@ -161,6 +161,8 @@ License: LicenseRef-Callaway-Python AND LicenseRef-Callaway-MIT AND Apache-2.0 A # General global macros # ===================== +%global pkgname python%{pybasever} + %global pylibdir %{_libdir}/python%{pybasever} %global dynload_dir %{pylibdir}/lib-dynload @@ -1131,6 +1133,16 @@ Provides: bundled(python3dist(appdirs)) = 1.4.3 Provides: bundled(mpdecimal) = %{libmpdec_version} Provides: bundled(libmpdec) = %{libmpdec_version} +# Provides of the subpackages contained in flatpackage +Provides: %{pkgname}-libs = %{version}-%{release} +Provides: %{pkgname}-devel = %{version}-%{release} +Provides: %{pkgname}-idle = %{version}-%{release} +Provides: %{pkgname}-tkinter = %{version}-%{release} +Provides: %{pkgname}-test = %{version}-%{release} +%if %{with debug_build} +Provides: %{pkgname}-debug = %{version}-%{release} +%endif + # The description for the flat package %description Python %{pybasever} package for developers. @@ -2094,6 +2106,9 @@ CheckPython optimized # ====================================================== %changelog +* Wed Apr 23 2025 Miro Hrončok - 3.6.15-46 +- Add RPM Provides for python3.6-libs, python3.6-devel, python3.6-idle, python3.6-tkinter, python3.6-test + * Wed Apr 16 2025 Charalampos Stratakis - 3.6.15-45 - Fix the flakiness of test_ftplib From 47fdac40696c7c5b89952abffe0203526f49b6b0 Mon Sep 17 00:00:00 2001 From: Lumir Balhar Date: Thu, 26 Jun 2025 21:58:58 +0200 Subject: [PATCH 02/10] Security fixes for CVE-2025-4517, CVE-2025-4330, CVE-2025-4138, CVE-2024-12718, CVE-2025-4435 --- 00465-tarfile-cves.patch | 1791 ++++++++++++++++++++++++++++++++++++++ python3.6.spec | 26 +- 2 files changed, 1816 insertions(+), 1 deletion(-) create mode 100644 00465-tarfile-cves.patch diff --git a/00465-tarfile-cves.patch b/00465-tarfile-cves.patch new file mode 100644 index 0000000..048f40c --- /dev/null +++ b/00465-tarfile-cves.patch @@ -0,0 +1,1791 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Petr Viktorin +Date: Tue, 22 Aug 2023 20:28:10 +0200 +Subject: 00465: tarfile cves + +Security fixes for CVE-2025-4517, CVE-2025-4330, CVE-2025-4138, CVE-2024-12718, CVE-2025-4435 on tarfile + +The backported fixes do not contain changes for ntpath.py and related tests, +because the support for symlinks and junctions were added later in Python 3.9, +and it does not make sense to backport them to 3.6 here. + +The patch is contains the following changes: +- https://github.com/python/cpython/commit/42deeab5b2efc2930d4eb73416e1dde9cf790dd2 + fixes symlink handling for tarfile.data_filter +- https://github.com/python/cpython/commit/9d2c2a8e3b8fe18ee1568bfa4a419847b3e78575 + fixes handling of existing files/symlinks in tarfile +- https://github.com/python/cpython/commit/00af9794dd118f7b835dd844b2b609a503ad951e + adds a new "strict" argument to realpath() +- https://github.com/python/cpython/commit/dd8f187d0746da151e0025c51680979ac5b4cfb1 + fixes mulriple CVE fixes in the tarfile module +- downstream only fixes that makes the changes work and compatible with Python 3.6 +--- + Doc/library/tarfile.rst | 5 + + Lib/genericpath.py | 12 +- + Lib/pathlib.py | 142 ++---- + Lib/posixpath.py | 35 +- + Lib/tarfile.py | 173 +++++-- + Lib/test/test_ntpath.py | 2 + + Lib/test/test_posixpath.py | 312 ++++++++++-- + Lib/test/test_tarfile.py | 459 +++++++++++++++++- + ...-08-10-17-36-22.gh-issue-107845.dABiMJ.rst | 3 + + 9 files changed, 938 insertions(+), 205 deletions(-) + create mode 100644 Misc/NEWS.d/next/Library/2023-08-10-17-36-22.gh-issue-107845.dABiMJ.rst + +diff --git a/Doc/library/tarfile.rst b/Doc/library/tarfile.rst +index f6dc9a7038..d6ef4551b1 100644 +--- a/Doc/library/tarfile.rst ++++ b/Doc/library/tarfile.rst +@@ -716,6 +716,11 @@ A ``TarInfo`` object has the following public data attributes: + Name of the target file name, which is only present in :class:`TarInfo` objects + of type :const:`LNKTYPE` and :const:`SYMTYPE`. + ++ For symbolic links (``SYMTYPE``), the *linkname* is relative to the directory ++ that contains the link. ++ For hard links (``LNKTYPE``), the *linkname* is relative to the root of ++ the archive. ++ + + .. attribute:: TarInfo.uid + +diff --git a/Lib/genericpath.py b/Lib/genericpath.py +index 303b3b349a..4a80461fe7 100644 +--- a/Lib/genericpath.py ++++ b/Lib/genericpath.py +@@ -8,7 +8,7 @@ import stat + + __all__ = ['commonprefix', 'exists', 'getatime', 'getctime', 'getmtime', + 'getsize', 'isdir', 'isfile', 'samefile', 'sameopenfile', +- 'samestat'] ++ 'samestat', 'ALLOW_MISSING'] + + + # Does a path exist? +@@ -149,3 +149,13 @@ def _check_arg_types(funcname, *args): + (funcname, s.__class__.__name__)) 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/pathlib.py b/Lib/pathlib.py +index 5e130110f2..0f0fc3863a 100644 +--- a/Lib/pathlib.py ++++ b/Lib/pathlib.py +@@ -8,22 +8,13 @@ import re + import sys + from collections import Sequence + from contextlib import contextmanager +-from errno import EINVAL, ENOENT, ENOTDIR ++from errno import EINVAL, ENOENT, ENOTDIR, ELOOP + from operator import attrgetter + from stat import S_ISDIR, S_ISLNK, S_ISREG, S_ISSOCK, S_ISBLK, S_ISCHR, S_ISFIFO + from urllib.parse import quote_from_bytes as urlquote_from_bytes + + + supports_symlinks = True +-if os.name == 'nt': +- import nt +- if sys.getwindowsversion()[:2] >= (6, 0): +- from nt import _getfinalpathname +- else: +- supports_symlinks = False +- _getfinalpathname = None +-else: +- nt = None + + + __all__ = [ +@@ -35,6 +26,11 @@ __all__ = [ + # Internals + # + ++_WINERROR_NOT_READY = 21 # drive exists but is not accessible ++_WINERROR_INVALID_NAME = 123 # fix for bpo-35306 ++_WINERROR_CANT_RESOLVE_FILENAME = 1921 # broken symlink pointing to itself ++ ++ + def _is_wildcard_pattern(pat): + # Whether this pattern needs actual matching using fnmatch, or can + # be looked up directly as a file. +@@ -178,30 +174,6 @@ class _WindowsFlavour(_Flavour): + def casefold_parts(self, parts): + return [p.lower() for p in parts] + +- def resolve(self, path, strict=False): +- s = str(path) +- if not s: +- return os.getcwd() +- previous_s = None +- if _getfinalpathname is not None: +- if strict: +- return self._ext_to_normal(_getfinalpathname(s)) +- else: +- tail_parts = [] # End of the path after the first one not found +- while True: +- try: +- s = self._ext_to_normal(_getfinalpathname(s)) +- except FileNotFoundError: +- previous_s = s +- s, tail = os.path.split(s) +- tail_parts.append(tail) +- if previous_s == s: +- return path +- else: +- return os.path.join(s, *reversed(tail_parts)) +- # Means fallback on absolute +- return None +- + def _split_extended_path(self, s, ext_prefix=ext_namespace_prefix): + prefix = '' + if s.startswith(ext_prefix): +@@ -212,10 +184,6 @@ class _WindowsFlavour(_Flavour): + s = '\\' + s[3:] + return prefix, s + +- def _ext_to_normal(self, s): +- # Turn back an extended path into a normal DOS-like path +- return self._split_extended_path(s)[1] +- + def is_reserved(self, parts): + # NOTE: the rules for reserved names seem somewhat complicated + # (e.g. r"..\NUL" is reserved but not r"foo\NUL"). +@@ -300,51 +268,6 @@ class _PosixFlavour(_Flavour): + def casefold_parts(self, parts): + return parts + +- def resolve(self, path, strict=False): +- sep = self.sep +- accessor = path._accessor +- seen = {} +- def _resolve(path, rest): +- if rest.startswith(sep): +- path = '' +- +- for name in rest.split(sep): +- if not name or name == '.': +- # current dir +- continue +- if name == '..': +- # parent dir +- path, _, _ = path.rpartition(sep) +- continue +- newpath = path + sep + name +- if newpath in seen: +- # Already seen this path +- path = seen[newpath] +- if path is not None: +- # use cached value +- continue +- # The symlink is not resolved, so we must have a symlink loop. +- raise RuntimeError("Symlink loop from %r" % newpath) +- # Resolve the symbolic link +- try: +- target = accessor.readlink(newpath) +- except OSError as e: +- if e.errno != EINVAL and strict: +- raise +- # Not a symlink, or non-strict mode. We just leave the path +- # untouched. +- path = newpath +- else: +- seen[newpath] = None # not resolved symlink +- path = _resolve(path, target) +- seen[newpath] = path # resolved symlink +- +- return path +- # NOTE: according to POSIX, getcwd() cannot contain path components +- # which are symlinks. +- base = '' if path.is_absolute() else os.getcwd() +- return _resolve(base, str(path)) or sep +- + def is_reserved(self, parts): + return False + +@@ -421,17 +344,12 @@ class _NormalAccessor(_Accessor): + + replace = _wrap_binary_strfunc(os.replace) + +- if nt: +- if supports_symlinks: +- symlink = _wrap_binary_strfunc(os.symlink) +- else: +- def symlink(a, b, target_is_directory): +- raise NotImplementedError("symlink() not available on this system") ++ if hasattr(os, "symlink"): ++ symlink = os.symlink + else: +- # Under POSIX, os.symlink() takes two args +- @staticmethod +- def symlink(a, b, target_is_directory): +- return os.symlink(str(a), str(b)) ++ def symlink(self, src, dst, target_is_directory=False): ++ raise NotImplementedError("os.symlink() not available on this system") ++ + + utime = _wrap_strfunc(os.utime) + +@@ -439,6 +357,12 @@ class _NormalAccessor(_Accessor): + def readlink(self, path): + return os.readlink(path) + ++ getcwd = os.getcwd ++ ++ expanduser = staticmethod(os.path.expanduser) ++ ++ realpath = staticmethod(os.path.realpath) ++ + + _normal_accessor = _NormalAccessor() + +@@ -1138,17 +1062,27 @@ class Path(PurePath): + """ + if self._closed: + self._raise_closed() +- s = self._flavour.resolve(self, strict=strict) +- if s is None: +- # No symlink resolution => for consistency, raise an error if +- # the path doesn't exist or is forbidden +- self.stat() +- s = str(self.absolute()) +- # Now we have no symlinks in the path, it's safe to normalize it. +- normed = self._flavour.pathmod.normpath(s) +- obj = self._from_parts((normed,), init=False) +- obj._init(template=self) +- return obj ++ ++ def check_eloop(e): ++ winerror = getattr(e, 'winerror', 0) ++ if e.errno == ELOOP or winerror == _WINERROR_CANT_RESOLVE_FILENAME: ++ raise RuntimeError("Symlink loop from %r" % e.filename) ++ ++ try: ++ s = self._accessor.realpath(self, strict=strict) ++ except OSError as e: ++ check_eloop(e) ++ raise ++ p = self._from_parts((s,)) ++ ++ # In non-strict mode, realpath() doesn't raise on symlink loops. ++ # Ensure we get an exception by calling stat() ++ if not strict: ++ try: ++ p.stat() ++ except OSError as e: ++ check_eloop(e) ++ return p + + def stat(self): + """ +diff --git a/Lib/posixpath.py b/Lib/posixpath.py +index ca578a5df3..bbc6fa4500 100644 +--- a/Lib/posixpath.py ++++ b/Lib/posixpath.py +@@ -35,7 +35,7 @@ __all__ = ["normcase","isabs","join","splitdrive","split","splitext", + "samefile","sameopenfile","samestat", + "curdir","pardir","sep","pathsep","defpath","altsep","extsep", + "devnull","realpath","supports_unicode_filenames","relpath", +- "commonpath"] ++ "commonpath", "ALLOW_MISSING"] + + + def _get_sep(path): +@@ -388,16 +388,16 @@ def abspath(path): + # Return a canonical path (i.e. the absolute location of a file on the + # filesystem). + +-def realpath(filename): ++def realpath(filename, *, strict=False): + """Return the canonical path of the specified filename, eliminating any + symbolic links encountered in the path.""" + filename = os.fspath(filename) +- path, ok = _joinrealpath(filename[:0], filename, {}) ++ path, ok = _joinrealpath(filename[:0], filename, strict, {}) + return abspath(path) + + # Join two paths, normalizing and eliminating any symbolic links + # encountered in the second path. +-def _joinrealpath(path, rest, seen): ++def _joinrealpath(path, rest, strict, seen): + if isinstance(path, bytes): + sep = b'/' + curdir = b'.' +@@ -406,6 +406,15 @@ def _joinrealpath(path, rest, 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:] +@@ -426,7 +435,13 @@ def _joinrealpath(path, rest, seen): + path = pardir + continue + newpath = join(path, name) +- if not islink(newpath): ++ try: ++ st = os.lstat(newpath) ++ except ignored_error: ++ is_link = False ++ else: ++ is_link = stat.S_ISLNK(st.st_mode) ++ if not is_link: + path = newpath + continue + # Resolve the symbolic link +@@ -437,10 +452,14 @@ def _joinrealpath(path, rest, seen): + # use cached value + continue + # The symlink is not resolved, so we must have a symlink loop. +- # Return already resolved part + rest of the path unchanged. +- return join(newpath, rest), False ++ if strict: ++ # Raise OSError(errno.ELOOP) ++ os.stat(newpath) ++ else: ++ # Return already resolved part + rest of the path unchanged. ++ return join(newpath, rest), False + seen[newpath] = None # not resolved symlink +- path, ok = _joinrealpath(path, os.readlink(newpath), seen) ++ path, ok = _joinrealpath(path, os.readlink(newpath), strict, seen) + if not ok: + return join(path, rest), False + seen[newpath] = path # resolved symlink +diff --git a/Lib/tarfile.py b/Lib/tarfile.py +index ee1bf37bfd..1a7d5f772a 100755 +--- a/Lib/tarfile.py ++++ b/Lib/tarfile.py +@@ -753,7 +753,7 @@ class SpecialFileError(FilterError): + class AbsoluteLinkError(FilterError): + def __init__(self, tarinfo): + self.tarinfo = tarinfo +- super().__init__(f'{tarinfo.name!r} is a symlink to an absolute path') ++ super().__init__(f'{tarinfo.name!r} is a link to an absolute path') + + class LinkOutsideDestinationError(FilterError): + def __init__(self, tarinfo, path): +@@ -762,10 +762,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)): +@@ -775,7 +787,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) +@@ -813,7 +826,18 @@ 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) +- target_path = os.path.realpath(os.path.join(dest_path, member.linkname)) ++ 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), ++ member.linkname) ++ else: ++ target_path = os.path.join(dest_path, ++ member.linkname) ++ 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 +@@ -2236,30 +2260,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, +@@ -2275,41 +2327,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: +@@ -2366,9 +2433,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 +@@ -2397,7 +2468,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: +@@ -2479,26 +2553,57 @@ 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(): ++ if os.path.lexists(targetpath): ++ # Avoid FileExistsError on following os.symlink. ++ os.unlink(targetpath) + os.symlink(tarinfo.linkname, targetpath) ++ return + else: + if os.path.exists(tarinfo._link_target): + os.link(tarinfo._link_target, targetpath) +- else: +- self._extract_member(self._find_link_target(tarinfo), +- targetpath) ++ return + except symlink_exception: ++ keyerror_to_extracterror = True ++ ++ try: ++ unfiltered = self._find_link_target(tarinfo) ++ except KeyError: ++ if keyerror_to_extracterror: ++ raise ExtractError( ++ "unable to resolve link inside archive") ++ else: ++ raise ++ ++ if filter_function is None: ++ filtered = unfiltered ++ else: ++ if extraction_root is None: ++ raise ExtractError( ++ "makelink_with_filter: if filter_function is not None, " ++ + "extraction_root must also not be None") + try: +- self._extract_member(self._find_link_target(tarinfo), +- targetpath) +- except KeyError: +- raise ExtractError("unable to resolve link inside archive") ++ filtered = filter_function(unfiltered, extraction_root) ++ except _FILTER_ERRORS as cause: ++ raise LinkFallbackError(tarinfo, unfiltered.name) from cause ++ if filtered is not None: ++ self._extract_member(filtered, targetpath, ++ filter_function=filter_function, ++ extraction_root=extraction_root) + + def chown(self, tarinfo, targetpath, numeric_owner): + """Set owner of targetpath according to tarinfo. If numeric_owner +diff --git a/Lib/test/test_ntpath.py b/Lib/test/test_ntpath.py +index bba1712cce..6666cea9f5 100644 +--- a/Lib/test/test_ntpath.py ++++ b/Lib/test/test_ntpath.py +@@ -1,8 +1,10 @@ + import ntpath + import os ++import subprocess + import sys + import unittest + import warnings ++from ntpath import ALLOW_MISSING + from test.support import TestFailed, FakePath + from test import support, test_genericpath + from tempfile import TemporaryFile +diff --git a/Lib/test/test_posixpath.py b/Lib/test/test_posixpath.py +index e73b31cb64..f41d032e26 100644 +--- a/Lib/test/test_posixpath.py ++++ b/Lib/test/test_posixpath.py +@@ -1,8 +1,10 @@ + import os ++import sys + import posixpath + import unittest + import warnings +-from posixpath import realpath, abspath, dirname, basename ++from functools import partial ++from posixpath import realpath, abspath, dirname, basename, ALLOW_MISSING + from test import support, test_genericpath + from test.support import FakePath + from unittest import mock +@@ -33,6 +35,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): +@@ -308,52 +330,167 @@ class PosixPathTest(unittest.TestCase): + b"/foo/bar") + + @skip_if_ABSTFN_contains_backslash +- def test_realpath_curdir(self): +- self.assertEqual(realpath('.'), os.getcwd()) +- self.assertEqual(realpath('./.'), os.getcwd()) +- self.assertEqual(realpath('/'.join(['.'] * 100)), os.getcwd()) ++ @_parameterize({}, {'strict': True}, {'strict': ALLOW_MISSING}) ++ def test_realpath_curdir(self, kwargs): ++ self.assertEqual(realpath('.', **kwargs), os.getcwd()) ++ self.assertEqual(realpath('./.', **kwargs), os.getcwd()) ++ self.assertEqual(realpath('/'.join(['.'] * 100), **kwargs), os.getcwd()) + +- self.assertEqual(realpath(b'.'), os.getcwdb()) +- self.assertEqual(realpath(b'./.'), os.getcwdb()) +- self.assertEqual(realpath(b'/'.join([b'.'] * 100)), os.getcwdb()) ++ self.assertEqual(realpath(b'.', **kwargs), os.getcwdb()) ++ self.assertEqual(realpath(b'./.', **kwargs), os.getcwdb()) ++ self.assertEqual(realpath(b'/'.join([b'.'] * 100), **kwargs), os.getcwdb()) + + @skip_if_ABSTFN_contains_backslash +- def test_realpath_pardir(self): +- self.assertEqual(realpath('..'), dirname(os.getcwd())) +- self.assertEqual(realpath('../..'), dirname(dirname(os.getcwd()))) +- self.assertEqual(realpath('/'.join(['..'] * 100)), '/') ++ @_parameterize({}, {'strict': True}, {'strict': ALLOW_MISSING}) ++ def test_realpath_pardir(self, kwargs): ++ self.assertEqual(realpath('..', **kwargs), dirname(os.getcwd())) ++ self.assertEqual(realpath('../..', **kwargs), dirname(dirname(os.getcwd()))) ++ self.assertEqual(realpath('/'.join(['..'] * 100), **kwargs), '/') + +- self.assertEqual(realpath(b'..'), dirname(os.getcwdb())) +- self.assertEqual(realpath(b'../..'), dirname(dirname(os.getcwdb()))) +- self.assertEqual(realpath(b'/'.join([b'..'] * 100)), b'/') ++ self.assertEqual(realpath(b'..', **kwargs), dirname(os.getcwdb())) ++ self.assertEqual(realpath(b'../..', **kwargs), dirname(dirname(os.getcwdb()))) ++ self.assertEqual(realpath(b'/'.join([b'..'] * 100), **kwargs), b'/') + + @unittest.skipUnless(hasattr(os, "symlink"), + "Missing symlink implementation") + @skip_if_ABSTFN_contains_backslash +- def test_realpath_basic(self): ++ @_parameterize({}, {'strict': ALLOW_MISSING}) ++ def test_realpath_basic(self, kwargs): + # Basic operation. + try: + os.symlink(ABSTFN+"1", ABSTFN) +- self.assertEqual(realpath(ABSTFN), ABSTFN+"1") ++ self.assertEqual(realpath(ABSTFN, **kwargs), ABSTFN+"1") + finally: + support.unlink(ABSTFN) + + @unittest.skipUnless(hasattr(os, "symlink"), + "Missing symlink implementation") + @skip_if_ABSTFN_contains_backslash +- def test_realpath_relative(self): ++ @_parameterize({}, {'strict': ALLOW_MISSING}) ++ def test_realpath_relative(self, kwargs): + try: + os.symlink(posixpath.relpath(ABSTFN+"1"), ABSTFN) +- self.assertEqual(realpath(ABSTFN), ABSTFN+"1") ++ self.assertEqual(realpath(ABSTFN, **kwargs), ABSTFN+"1") + finally: + support.unlink(ABSTFN) + + @unittest.skipUnless(hasattr(os, "symlink"), + "Missing symlink implementation") + @skip_if_ABSTFN_contains_backslash ++ def test_realpath_strict(self): ++ # Bug #43757: raise FileNotFoundError in strict mode if we encounter ++ # a path that does not exist. ++ try: ++ os.symlink(ABSTFN+"1", ABSTFN) ++ self.assertRaises(FileNotFoundError, realpath, ABSTFN, strict=True) ++ self.assertRaises(FileNotFoundError, realpath, ABSTFN + "2", strict=True) ++ finally: ++ support.unlink(ABSTFN) ++ ++ def test_realpath_invalid_paths(self): ++ path = '/\x00' ++ self.assertRaises(ValueError, realpath, path, strict=False) ++ self.assertRaises(ValueError, realpath, path, strict=True) ++ self.assertRaises(ValueError, realpath, path, strict=ALLOW_MISSING) ++ path = b'/\x00' ++ self.assertRaises(ValueError, realpath, path, strict=False) ++ self.assertRaises(ValueError, realpath, path, strict=True) ++ self.assertRaises(ValueError, realpath, path, strict=ALLOW_MISSING) ++ path = '/nonexistent/x\x00' ++ self.assertRaises(ValueError, realpath, path, strict=False) ++ self.assertRaises(FileNotFoundError, realpath, path, strict=True) ++ self.assertRaises(ValueError, realpath, path, strict=ALLOW_MISSING) ++ path = b'/nonexistent/x\x00' ++ self.assertRaises(ValueError, realpath, path, strict=False) ++ self.assertRaises(FileNotFoundError, realpath, path, strict=True) ++ self.assertRaises(ValueError, realpath, path, strict=ALLOW_MISSING) ++ path = '/\x00/..' ++ self.assertRaises(ValueError, realpath, path, strict=False) ++ self.assertRaises(ValueError, realpath, path, strict=True) ++ self.assertRaises(ValueError, realpath, path, strict=ALLOW_MISSING) ++ path = b'/\x00/..' ++ self.assertRaises(ValueError, realpath, path, strict=False) ++ self.assertRaises(ValueError, realpath, path, strict=True) ++ self.assertRaises(ValueError, realpath, path, strict=ALLOW_MISSING) ++ ++ path = '/nonexistent/x\x00/..' ++ self.assertRaises(ValueError, realpath, path, strict=False) ++ self.assertRaises(FileNotFoundError, realpath, path, strict=True) ++ self.assertRaises(ValueError, realpath, path, strict=ALLOW_MISSING) ++ path = b'/nonexistent/x\x00/..' ++ self.assertRaises(ValueError, realpath, path, strict=False) ++ self.assertRaises(FileNotFoundError, realpath, path, strict=True) ++ self.assertRaises(ValueError, realpath, path, strict=ALLOW_MISSING) ++ ++ path = '/\udfff' ++ if sys.platform == 'win32': ++ self.assertEqual(realpath(path, strict=False), path) ++ self.assertRaises(FileNotFoundError, realpath, path, strict=True) ++ self.assertEqual(realpath(path, strict=ALLOW_MISSING), path) ++ else: ++ self.assertRaises(UnicodeEncodeError, realpath, path, strict=False) ++ self.assertRaises(UnicodeEncodeError, realpath, path, strict=True) ++ self.assertRaises(UnicodeEncodeError, realpath, path, strict=ALLOW_MISSING) ++ path = '/nonexistent/\udfff' ++ if sys.platform == 'win32': ++ self.assertEqual(realpath(path, strict=False), path) ++ self.assertEqual(realpath(path, strict=ALLOW_MISSING), path) ++ else: ++ self.assertRaises(UnicodeEncodeError, realpath, path, strict=False) ++ self.assertRaises(UnicodeEncodeError, realpath, path, strict=ALLOW_MISSING) ++ self.assertRaises(FileNotFoundError, realpath, path, strict=True) ++ path = '/\udfff/..' ++ if sys.platform == 'win32': ++ self.assertEqual(realpath(path, strict=False), '/') ++ self.assertRaises(FileNotFoundError, realpath, path, strict=True) ++ self.assertEqual(realpath(path, strict=ALLOW_MISSING), '/') ++ else: ++ self.assertRaises(UnicodeEncodeError, realpath, path, strict=False) ++ self.assertRaises(UnicodeEncodeError, realpath, path, strict=True) ++ self.assertRaises(UnicodeEncodeError, realpath, path, strict=ALLOW_MISSING) ++ path = '/nonexistent/\udfff/..' ++ if sys.platform == 'win32': ++ self.assertEqual(realpath(path, strict=False), '/nonexistent') ++ self.assertEqual(realpath(path, strict=ALLOW_MISSING), '/nonexistent') ++ else: ++ self.assertRaises(UnicodeEncodeError, realpath, path, strict=False) ++ self.assertRaises(UnicodeEncodeError, realpath, path, strict=ALLOW_MISSING) ++ self.assertRaises(FileNotFoundError, realpath, path, strict=True) ++ ++ path = b'/\xff' ++ if sys.platform == 'win32': ++ self.assertRaises(UnicodeDecodeError, realpath, path, strict=False) ++ self.assertRaises(UnicodeDecodeError, realpath, path, strict=True) ++ self.assertRaises(UnicodeDecodeError, realpath, path, strict=ALLOW_MISSING) ++ else: ++ self.assertEqual(realpath(path, strict=False), path) ++ self.assertRaises(FileNotFoundError, realpath, path, strict=True) ++ self.assertEqual(realpath(path, strict=ALLOW_MISSING), path) ++ path = b'/nonexistent/\xff' ++ if sys.platform == 'win32': ++ self.assertRaises(UnicodeDecodeError, realpath, path, strict=False) ++ self.assertRaises(UnicodeDecodeError, realpath, path, strict=ALLOW_MISSING) ++ else: ++ self.assertEqual(realpath(path, strict=False), path) ++ self.assertRaises(FileNotFoundError, realpath, path, strict=True) ++ ++ @unittest.skipUnless(hasattr(os, "symlink"), ++ "Missing symlink implementation") ++ @skip_if_ABSTFN_contains_backslash ++ @_parameterize({}, {'strict': ALLOW_MISSING}) ++ def test_realpath_missing_pardir(self, kwargs): ++ try: ++ os.symlink(support.TESTFN + "1", support.TESTFN) ++ self.assertEqual( ++ realpath("nonexistent/../" + support.TESTFN, **kwargs), ABSTFN + "1") ++ finally: ++ support.unlink(support.TESTFN) ++ ++ @support.skip_unless_symlink ++ @skip_if_ABSTFN_contains_backslash + def test_realpath_symlink_loops(self): + # Bug #930024, return the path unchanged if we get into an infinite +- # symlink loop. ++ # symlink loop in non-strict mode (default). + try: + os.symlink(ABSTFN, ABSTFN) + self.assertEqual(realpath(ABSTFN), ABSTFN) +@@ -393,13 +530,57 @@ class PosixPathTest(unittest.TestCase): + @unittest.skipUnless(hasattr(os, "symlink"), + "Missing symlink implementation") + @skip_if_ABSTFN_contains_backslash +- def test_realpath_repeated_indirect_symlinks(self): ++ @_parameterize({'strict': True}, {'strict': ALLOW_MISSING}) ++ def test_realpath_symlink_loops_strict(self, kwargs): ++ # Bug #43757, raise OSError if we get into an infinite symlink loop in ++ # the strict modes. ++ try: ++ os.symlink(ABSTFN, ABSTFN) ++ self.assertRaises(OSError, realpath, ABSTFN, **kwargs) ++ ++ os.symlink(ABSTFN+"1", ABSTFN+"2") ++ os.symlink(ABSTFN+"2", ABSTFN+"1") ++ self.assertRaises(OSError, realpath, ABSTFN+"1", **kwargs) ++ self.assertRaises(OSError, realpath, ABSTFN+"2", **kwargs) ++ ++ 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", **kwargs) ++ self.assertRaises(OSError, realpath, ++ ABSTFN+"1/../" + basename(ABSTFN) + "1", **kwargs) ++ ++ os.symlink(basename(ABSTFN) + "a/b", ABSTFN+"a") ++ self.assertRaises(OSError, realpath, ABSTFN+"a", **kwargs) ++ ++ os.symlink("../" + basename(dirname(ABSTFN)) + "/" + ++ basename(ABSTFN) + "c", ABSTFN+"c") ++ self.assertRaises(OSError, realpath, ABSTFN+"c", **kwargs) ++ ++ # Test using relative path as well. ++ with support.change_cwd(dirname(ABSTFN)): ++ self.assertRaises(OSError, realpath, basename(ABSTFN), **kwargs) ++ finally: ++ support.unlink(ABSTFN) ++ support.unlink(ABSTFN+"1") ++ support.unlink(ABSTFN+"2") ++ support.unlink(ABSTFN+"y") ++ support.unlink(ABSTFN+"c") ++ support.unlink(ABSTFN+"a") ++ ++ @unittest.skipUnless(hasattr(os, "symlink"), ++ "Missing symlink implementation") ++ @skip_if_ABSTFN_contains_backslash ++ @_parameterize({}, {'strict': True}, {'strict': ALLOW_MISSING}) ++ def test_realpath_repeated_indirect_symlinks(self, kwargs): + # Issue #6975. + try: + os.mkdir(ABSTFN) + os.symlink('../' + basename(ABSTFN), ABSTFN + '/self') + os.symlink('self/self/self', ABSTFN + '/link') +- self.assertEqual(realpath(ABSTFN + '/link'), ABSTFN) ++ self.assertEqual(realpath(ABSTFN + '/link', **kwargs), ABSTFN) + finally: + support.unlink(ABSTFN + '/self') + support.unlink(ABSTFN + '/link') +@@ -408,14 +589,15 @@ class PosixPathTest(unittest.TestCase): + @unittest.skipUnless(hasattr(os, "symlink"), + "Missing symlink implementation") + @skip_if_ABSTFN_contains_backslash +- def test_realpath_deep_recursion(self): ++ @_parameterize({}, {'strict': True}, {'strict': ALLOW_MISSING}) ++ def test_realpath_deep_recursion(self, kwargs): + depth = 10 + try: + os.mkdir(ABSTFN) + for i in range(depth): + os.symlink('/'.join(['%d' % i] * 10), ABSTFN + '/%d' % (i + 1)) + os.symlink('.', ABSTFN + '/0') +- self.assertEqual(realpath(ABSTFN + '/%d' % depth), ABSTFN) ++ self.assertEqual(realpath(ABSTFN + '/%d' % depth, **kwargs), ABSTFN) + + # Test using relative path as well. + with support.change_cwd(ABSTFN): +@@ -428,7 +610,8 @@ class PosixPathTest(unittest.TestCase): + @unittest.skipUnless(hasattr(os, "symlink"), + "Missing symlink implementation") + @skip_if_ABSTFN_contains_backslash +- def test_realpath_resolve_parents(self): ++ @_parameterize({}, {'strict': ALLOW_MISSING}) ++ def test_realpath_resolve_parents(self, kwargs): + # We also need to resolve any symlinks in the parents of a relative + # path passed to realpath. E.g.: current working directory is + # /usr/doc with 'doc' being a symlink to /usr/share/doc. We call +@@ -439,7 +622,8 @@ class PosixPathTest(unittest.TestCase): + os.symlink(ABSTFN + "/y", ABSTFN + "/k") + + with support.change_cwd(ABSTFN + "/k"): +- self.assertEqual(realpath("a"), ABSTFN + "/y/a") ++ self.assertEqual(realpath("a", **kwargs), ++ ABSTFN + "/y/a") + finally: + support.unlink(ABSTFN + "/k") + safe_rmdir(ABSTFN + "/y") +@@ -448,7 +632,8 @@ class PosixPathTest(unittest.TestCase): + @unittest.skipUnless(hasattr(os, "symlink"), + "Missing symlink implementation") + @skip_if_ABSTFN_contains_backslash +- def test_realpath_resolve_before_normalizing(self): ++ @_parameterize({}, {'strict': True}, {'strict': ALLOW_MISSING}) ++ def test_realpath_resolve_before_normalizing(self, kwargs): + # Bug #990669: Symbolic links should be resolved before we + # normalize the path. E.g.: if we have directories 'a', 'k' and 'y' + # in the following hierarchy: +@@ -463,10 +648,10 @@ class PosixPathTest(unittest.TestCase): + os.symlink(ABSTFN + "/k/y", ABSTFN + "/link-y") + + # Absolute path. +- self.assertEqual(realpath(ABSTFN + "/link-y/.."), ABSTFN + "/k") ++ self.assertEqual(realpath(ABSTFN + "/link-y/..", **kwargs), ABSTFN + "/k") + # Relative path. + with support.change_cwd(dirname(ABSTFN)): +- self.assertEqual(realpath(basename(ABSTFN) + "/link-y/.."), ++ self.assertEqual(realpath(basename(ABSTFN) + "/link-y/..", **kwargs), + ABSTFN + "/k") + finally: + support.unlink(ABSTFN + "/link-y") +@@ -477,7 +662,8 @@ class PosixPathTest(unittest.TestCase): + @unittest.skipUnless(hasattr(os, "symlink"), + "Missing symlink implementation") + @skip_if_ABSTFN_contains_backslash +- def test_realpath_resolve_first(self): ++ @_parameterize({}, {'strict': True}, {'strict': ALLOW_MISSING}) ++ def test_realpath_resolve_first(self, kwargs): + # Bug #1213894: The first component of the path, if not absolute, + # must be resolved too. + +@@ -487,13 +673,70 @@ class PosixPathTest(unittest.TestCase): + os.symlink(ABSTFN, ABSTFN + "link") + with support.change_cwd(dirname(ABSTFN)): + base = basename(ABSTFN) +- self.assertEqual(realpath(base + "link"), ABSTFN) +- self.assertEqual(realpath(base + "link/k"), ABSTFN + "/k") ++ self.assertEqual(realpath(base + "link", **kwargs), ABSTFN) ++ self.assertEqual(realpath(base + "link/k", **kwargs), ABSTFN + "/k") + finally: + support.unlink(ABSTFN + "link") + safe_rmdir(ABSTFN + "/k") + safe_rmdir(ABSTFN) + ++ @support.skip_unless_symlink ++ @skip_if_ABSTFN_contains_backslash ++ @unittest.skipIf(os.chmod not in os.supports_follow_symlinks, "Can't set symlink permissions") ++ @unittest.skipIf(sys.platform != "darwin", "only macOS requires read permission to readlink()") ++ @_parameterize({'strict': True}, {'strict': ALLOW_MISSING}) ++ def test_realpath_unreadable_symlink_strict(self, kwargs): ++ try: ++ os.symlink(ABSTFN+"1", ABSTFN) ++ os.chmod(ABSTFN, 0o000, follow_symlinks=False) ++ with self.assertRaises(PermissionError): ++ realpath(ABSTFN, **kwargs) ++ with self.assertRaises(PermissionError): ++ realpath(ABSTFN + '/foo', **kwargs), ++ with self.assertRaises(PermissionError): ++ realpath(ABSTFN + '/../foo', **kwargs) ++ with self.assertRaises(PermissionError): ++ realpath(ABSTFN + '/foo/..', **kwargs) ++ finally: ++ os.chmod(ABSTFN, 0o755, follow_symlinks=False) ++ os.unlink(ABSTFN) ++ ++ @skip_if_ABSTFN_contains_backslash ++ @support.skip_unless_symlink ++ def test_realpath_unreadable_directory(self): ++ try: ++ os.mkdir(ABSTFN) ++ os.mkdir(ABSTFN + '/k') ++ os.chmod(ABSTFN, 0o000) ++ self.assertEqual(realpath(ABSTFN, strict=False), ABSTFN) ++ self.assertEqual(realpath(ABSTFN, strict=True), ABSTFN) ++ self.assertEqual(realpath(ABSTFN, strict=ALLOW_MISSING), ABSTFN) ++ ++ try: ++ os.stat(ABSTFN) ++ except PermissionError: ++ pass ++ else: ++ self.skipTest('Cannot block permissions') ++ ++ self.assertEqual(realpath(ABSTFN + '/k', strict=False), ++ ABSTFN + '/k') ++ self.assertRaises(PermissionError, realpath, ABSTFN + '/k', ++ strict=True) ++ self.assertRaises(PermissionError, realpath, ABSTFN + '/k', ++ strict=ALLOW_MISSING) ++ ++ self.assertEqual(realpath(ABSTFN + '/missing', strict=False), ++ ABSTFN + '/missing') ++ self.assertRaises(PermissionError, realpath, ABSTFN + '/missing', ++ strict=True) ++ self.assertRaises(PermissionError, realpath, ABSTFN + '/missing', ++ strict=ALLOW_MISSING) ++ finally: ++ os.chmod(ABSTFN, 0o755) ++ safe_rmdir(ABSTFN + '/k') ++ safe_rmdir(ABSTFN) ++ + def test_relpath(self): + (real_getcwd, os.getcwd) = (os.getcwd, lambda: r"/home/user/bar") + try: +@@ -670,9 +913,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 04ef000e71..c5d837e716 100644 +--- a/Lib/test/test_tarfile.py ++++ b/Lib/test/test_tarfile.py +@@ -1,3 +1,4 @@ ++import errno + import sys + import os + import io +@@ -1360,11 +1361,11 @@ class WriteTest(WriteTestBase, unittest.TestCase): + f.write('something\n') + os.symlink(source_file, target_file) + tar = tarfile.open(temparchive,'w') +- tar.add(source_file) +- tar.add(target_file) ++ tar.add(source_file, arcname="source") ++ tar.add(target_file, arcname="symlink") + tar.close() + # Let's extract it to the location which contains the symlink +- tar = tarfile.open(temparchive,'r') ++ tar = tarfile.open(temparchive,'r', errorlevel=2) + # this should not raise OSError: [Errno 17] File exists + try: + tar.extractall(path=tempdir) +@@ -2242,9 +2243,35 @@ class MiscTest(unittest.TestCase): + 'tar_filter', 'FilterError', 'AbsoluteLinkError', + 'OutsideDestinationError', 'SpecialFileError', + 'AbsolutePathError', 'LinkOutsideDestinationError', ++ 'LinkFallbackError', + } + support.check__all__(self, tarfile, blacklist=blacklist) + ++ @unittest.skipUnless(support.can_symlink(), 'requires symlink support') ++ @unittest.skipUnless(hasattr(os, 'chmod'), "missing os.chmod") ++ @unittest.mock.patch('os.chmod') ++ def test_deferred_directory_attributes_update(self, mock_chmod): ++ # Regression test for gh-127987: setting attributes on arbitrary files ++ tempdir = os.path.join(TEMPDIR, 'test127987') ++ def mock_chmod_side_effect(path, mode, **kwargs): ++ target_path = os.path.realpath(path) ++ if os.path.commonpath([target_path, tempdir]) != tempdir: ++ raise Exception("should not try to chmod anything outside the destination", target_path) ++ mock_chmod.side_effect = mock_chmod_side_effect ++ ++ outside_tree_dir = os.path.join(TEMPDIR, 'outside_tree_dir') ++ with ArchiveMaker() as arc: ++ arc.add('x', symlink_to='.') ++ arc.add('x', type=tarfile.DIRTYPE, mode='?rwsrwsrwt') ++ arc.add('x', symlink_to=outside_tree_dir) ++ ++ os.makedirs(outside_tree_dir) ++ try: ++ arc.open().extractall(path=tempdir, filter='tar') ++ finally: ++ support.rmtree(outside_tree_dir) ++ support.rmtree(tempdir) ++ + + class CommandLineTest(unittest.TestCase): + +@@ -2777,6 +2804,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 +@@ -3003,10 +3034,28 @@ class ArchiveMaker: + self.bio = None + + def add(self, name, *, type=None, symlink_to=None, hardlink_to=None, +- mode=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: +@@ -3020,7 +3069,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) +@@ -3042,7 +3091,7 @@ class TestExtractionFilters(unittest.TestCase): + destdir = outerdir / 'dest' + + @contextmanager +- def check_context(self, tar, filter): ++ def check_context(self, tar, filter, *, check_flag=True, ignored_trees=()): + """Extracts `tar` to `self.destdir` and allows checking the result + + If an error occurs, it must be checked using `expect_exception` +@@ -3051,26 +3100,46 @@ class TestExtractionFilters(unittest.TestCase): + except the destination directory itself and parent directories of + other files. + When checking directories, do so before their contents. ++ ++ A file called 'flag' is made in outerdir (i.e. outside destdir) ++ before extraction; it should not be altered nor should its contents ++ be read/copied. ++ ++ *ignored_trees* is a set of directories to remove (including their ++ contents) right after the archive is extracted. It is a workaround ++ for Path.glob() failing to get all files in Python 3.10 and below. + """ + with support.temp_dir(self.outerdir): ++ flag_path = self.outerdir / 'flag' ++ flag_path.write_text('capture me') + try: + tar.extractall(self.destdir, filter=filter) + except Exception as exc: + self.raised_exception = exc ++ self.reraise_exception = True + self.expected_paths = set() + else: ++ for ignored_tree in ignored_trees: ++ support.rmtree((self.destdir / ignored_tree).resolve()) + self.raised_exception = None ++ self.reraise_exception = False + self.expected_paths = set(self.outerdir.glob('**/*')) + self.expected_paths.discard(self.destdir) ++ self.expected_paths.discard(flag_path) + try: +- yield ++ yield self + finally: + tar.close() +- if self.raised_exception: ++ if self.reraise_exception: + raise self.raised_exception + self.assertEqual(self.expected_paths, set()) ++ if check_flag: ++ self.assertEqual(flag_path.read_text(), 'capture me') ++ else: ++ assert filter == 'fully_trusted' + +- def expect_file(self, name, type=None, symlink_to=None, mode=None): ++ def expect_file(self, name, type=None, symlink_to=None, mode=None, ++ size=None, content=None): + """Check a single file. See check_context.""" + if self.raised_exception: + raise self.raised_exception +@@ -3080,7 +3149,7 @@ class TestExtractionFilters(unittest.TestCase): + self.expected_paths.remove(path) + + # When checking mode, ignore Windows (which can only set user read and +- # user write bits). Newer versions of Python use `os_helper.can_chmod()` ++ # user write bits). Newer versions of Python use `support.can_chmod()` + # instead of hardcoding Windows. + if mode is not None and sys.platform != 'win32': + got = stat.filemode(stat.S_IMODE(path.stat().st_mode)) +@@ -3094,24 +3163,47 @@ 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 ++ # add_note not supported on Python < 3.11 ++ # new style formating with = not supported in 3.6 as well ++ # 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: +@@ -3149,8 +3241,15 @@ class TestExtractionFilters(unittest.TestCase): + # Test interplaying symlinks + # Inspired by 'dirsymlink2a' in jwilk/traversal-archives + with ArchiveMaker() as arc: ++ ++ # `current` links to `.` which is both: ++ # - the destination directory ++ # - `current` itself + arc.add('current', symlink_to='.') ++ ++ # effectively points to ./../ + arc.add('parent', symlink_to='current/..') ++ + arc.add('parent/evil') + + if support.can_symlink(): +@@ -3188,12 +3287,121 @@ class TestExtractionFilters(unittest.TestCase): + with self.check_context(arc.open(), 'data'): + self.expect_file('parent/evil') + ++ @support.skip_unless_symlink ++ def test_realpath_limit_attack(self): ++ # (CVE-2025-4517) ++ ++ with ArchiveMaker() as arc: ++ # populate the symlinks and dirs that expand in os.path.realpath() ++ # The component length is chosen so that in common cases, the unexpanded ++ # path fits in PATH_MAX, but it overflows when the final symlink ++ # is expanded ++ steps = "abcdefghijklmnop" ++ if sys.platform == 'win32': ++ component = 'd' * 25 ++ elif 'PC_PATH_MAX' in os.pathconf_names: ++ max_path_len = os.pathconf(self.outerdir.parent, "PC_PATH_MAX") ++ path_sep_len = 1 ++ dest_len = len(str(self.destdir)) + path_sep_len ++ component_len = (max_path_len - dest_len) // (len(steps) + path_sep_len) ++ component = 'd' * component_len ++ else: ++ raise NotImplementedError("Need to guess component length for {sys.platform}") ++ path = "" ++ step_path = "" ++ for i in steps: ++ arc.add(os.path.join(path, component), type=tarfile.DIRTYPE, ++ mode='drwxrwxrwx') ++ arc.add(os.path.join(path, i), symlink_to=component) ++ path = os.path.join(path, component) ++ step_path = os.path.join(step_path, i) ++ # create the final symlink that exceeds PATH_MAX and simply points ++ # to the top dir. ++ # this link will never be expanded by ++ # os.path.realpath(strict=False), nor anything after it. ++ linkpath = os.path.join(*steps, "l"*254) ++ parent_segments = [".."] * len(steps) ++ arc.add(linkpath, symlink_to=os.path.join(*parent_segments)) ++ # make a symlink outside to keep the tar command happy ++ arc.add("escape", symlink_to=os.path.join(linkpath, "..")) ++ # use the symlinks above, that are not checked, to create a hardlink ++ # to a file outside of the destination path ++ arc.add("flaglink", hardlink_to=os.path.join("escape", "flag")) ++ # now that we have the hardlink we can overwrite the file ++ arc.add("flaglink", content='overwrite') ++ # we can also create new files as well! ++ arc.add("escape/newfile", content='new') ++ ++ with self.subTest('fully_trusted'), \ ++ self.check_context(arc.open(), filter='fully_trusted', ++ check_flag=False, ignored_trees={component}): ++ if sys.platform == 'win32': ++ self.expect_exception((FileNotFoundError, FileExistsError)) ++ elif self.raised_exception: ++ # Cannot symlink/hardlink: tarfile falls back to getmember() ++ self.expect_exception(KeyError) ++ # Otherwise, this block should never enter. ++ else: ++ self.expect_file('flaglink', content='overwrite') ++ self.expect_file('../newfile', content='new') ++ self.expect_file('escape', type=tarfile.SYMTYPE) ++ self.expect_file('a', symlink_to=component) ++ ++ for filter in 'tar', 'data': ++ with self.subTest(filter), self.check_context(arc.open(), filter=filter): ++ exc = self.expect_exception((OSError, KeyError)) ++ if isinstance(exc, OSError): ++ if sys.platform == 'win32': ++ # 3: ERROR_PATH_NOT_FOUND ++ # 5: ERROR_ACCESS_DENIED ++ # 206: ERROR_FILENAME_EXCED_RANGE ++ self.assertIn(exc.winerror, (3, 5, 206)) ++ else: ++ self.assertEqual(exc.errno, errno.ENAMETOOLONG) ++ + def test_parent_symlink2(self): + # Test interplaying symlinks + # Inspired by 'dirsymlink2b' in jwilk/traversal-archives ++ ++ # Posix and Windows have different pathname resolution: ++ # either symlink or a '..' component resolve first. ++ # Let's see which we are on. ++ if support.can_symlink(): ++ testpath = os.path.join(TEMPDIR, 'resolution_test') ++ os.mkdir(testpath) ++ ++ # testpath/current links to `.` which is all of: ++ # - `testpath` ++ # - `testpath/current` ++ # - `testpath/current/current` ++ # - etc. ++ os.symlink('.', os.path.join(testpath, 'current')) ++ ++ # we'll test where `testpath/current/../file` ends up ++ with open(os.path.join(testpath, 'current', '..', 'file'), 'w'): ++ pass ++ ++ if os.path.exists(os.path.join(testpath, 'file')): ++ # Windows collapses 'current\..' to '.' first, leaving ++ # 'testpath\file' ++ dotdot_resolves_early = True ++ elif os.path.exists(os.path.join(testpath, '..', 'file')): ++ # Posix resolves 'current' to '.' first, leaving ++ # 'testpath/../file' ++ dotdot_resolves_early = False ++ else: ++ raise AssertionError('Could not determine link resolution') ++ + with ArchiveMaker() as arc: ++ ++ # `current` links to `.` which is both the destination directory ++ # and `current` itself + arc.add('current', symlink_to='.') ++ ++ # `current/parent` is also available as `./parent`, ++ # and effectively points to `./../` + arc.add('current/parent', symlink_to='..') ++ + arc.add('parent/evil') + + with self.check_context(arc.open(), 'fully_trusted'): +@@ -3207,6 +3415,7 @@ class TestExtractionFilters(unittest.TestCase): + + with self.check_context(arc.open(), 'tar'): + if support.can_symlink(): ++ # Fail when extracting a file outside destination + self.expect_exception( + tarfile.OutsideDestinationError, + "'parent/evil' would be extracted to " +@@ -3217,10 +3426,24 @@ class TestExtractionFilters(unittest.TestCase): + self.expect_file('parent/evil') + + with self.check_context(arc.open(), 'data'): +- self.expect_exception( +- tarfile.LinkOutsideDestinationError, +- """'current/parent' would link to ['"].*['"], """ +- + "which is outside the destination") ++ if support.can_symlink(): ++ if dotdot_resolves_early: ++ # Fail when extracting a file outside destination ++ self.expect_exception( ++ tarfile.OutsideDestinationError, ++ "'parent/evil' would be extracted to " ++ + """['"].*evil['"], which is outside """ ++ + "the destination") ++ else: ++ # Fail as soon as we have a symlink outside the destination ++ self.expect_exception( ++ tarfile.LinkOutsideDestinationError, ++ "'current/parent' would link to " ++ + """['"].*outerdir['"], which is outside """ ++ + "the destination") ++ else: ++ self.expect_file('current/') ++ self.expect_file('parent/evil') + + def test_absolute_symlink(self): + # Test symlink to an absolute path +@@ -3249,11 +3472,29 @@ class TestExtractionFilters(unittest.TestCase): + with self.check_context(arc.open(), 'data'): + self.expect_exception( + tarfile.AbsoluteLinkError, +- "'parent' is a symlink to an absolute path") ++ "'parent' is a link to an absolute path") ++ ++ def test_absolute_hardlink(self): ++ # Test hardlink to an absolute path ++ # Inspired by 'dirsymlink' in https://github.com/jwilk/traversal-archives ++ with ArchiveMaker() as arc: ++ arc.add('parent', hardlink_to=self.outerdir / 'foo') ++ ++ with self.check_context(arc.open(), 'fully_trusted'): ++ self.expect_exception(KeyError, ".*foo. not found") ++ ++ with self.check_context(arc.open(), 'tar'): ++ self.expect_exception(KeyError, ".*foo. not found") ++ ++ with self.check_context(arc.open(), 'data'): ++ self.expect_exception( ++ tarfile.AbsoluteLinkError, ++ "'parent' is a link to an absolute path") + + def test_sly_relative0(self): + # Inspired by 'relative0' in jwilk/traversal-archives + with ArchiveMaker() as arc: ++ # points to `../../tmp/moo` + arc.add('../moo', symlink_to='..//tmp/moo') + + try: +@@ -3303,6 +3544,174 @@ class TestExtractionFilters(unittest.TestCase): + + """['"].*moo['"], which is outside the """ + + "destination") + ++ def test_deep_symlink(self): ++ # Test that symlinks and hardlinks inside a directory ++ # point to the correct file (`target` of size 3). ++ # If links aren't supported we get a copy of the file. ++ with ArchiveMaker() as arc: ++ arc.add('targetdir/target', size=3) ++ # a hardlink's linkname is relative to the archive ++ arc.add('linkdir/hardlink', hardlink_to=os.path.join( ++ 'targetdir', 'target')) ++ # a symlink's linkname is relative to the link's directory ++ arc.add('linkdir/symlink', symlink_to=os.path.join( ++ '..', 'targetdir', 'target')) ++ ++ for filter in 'tar', 'data', 'fully_trusted': ++ with self.check_context(arc.open(), filter): ++ self.expect_file('targetdir/target', size=3) ++ self.expect_file('linkdir/hardlink', size=3) ++ if support.can_symlink(): ++ self.expect_file('linkdir/symlink', size=3, ++ symlink_to='../targetdir/target') ++ else: ++ self.expect_file('linkdir/symlink', size=3) ++ ++ def test_chains(self): ++ # Test chaining of symlinks/hardlinks. ++ # Symlinks are created before the files they point to. ++ with ArchiveMaker() as arc: ++ arc.add('linkdir/symlink', symlink_to='hardlink') ++ 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') ++ ++ for filter in 'tar', 'data', 'fully_trusted': ++ with self.check_context(arc.open(), filter): ++ self.expect_file('targetdir/target', size=3) ++ self.expect_file('linkdir/hardlink', size=3) ++ self.expect_file('linkdir/hardlink2', size=3) ++ if support.can_symlink(): ++ self.expect_file('linkdir/symlink', size=3, ++ symlink_to='hardlink') ++ self.expect_file('symlink2', size=3, ++ symlink_to='linkdir/hardlink2') ++ else: ++ self.expect_file('linkdir/symlink', size=3) ++ self.expect_file('symlink2', size=3) ++ ++ def test_sneaky_hardlink_fallback(self): ++ # (CVE-2025-4330) ++ # Test that when hardlink extraction falls back to extracting members ++ # from the archive, the extracted member is (re-)filtered. ++ with ArchiveMaker() as arc: ++ # Create a directory structure so the c/escape symlink stays ++ # inside the path ++ arc.add("a/t/dummy") ++ # Create b/ directory ++ arc.add("b/") ++ # Point "c" to the bottom of the tree in "a" ++ arc.add("c", symlink_to=os.path.join("a", "t")) ++ # link to non-existant location under "a" ++ arc.add("c/escape", symlink_to=os.path.join("..", "..", ++ "link_here")) ++ # Move "c" to point to "b" ("c/escape" no longer exists) ++ arc.add("c", symlink_to="b") ++ # Attempt to create a hard link to "c/escape". Since it doesn't ++ # exist it will attempt to extract "cescape" but at "boom". ++ arc.add("boom", hardlink_to=os.path.join("c", "escape")) ++ ++ with self.check_context(arc.open(), 'data'): ++ if not support.can_symlink(): ++ # When 'c/escape' is extracted, 'c' is a regular ++ # directory, and 'c/escape' *would* point outside ++ # the destination if symlinks were allowed. ++ self.expect_exception( ++ tarfile.LinkOutsideDestinationError) ++ elif sys.platform == "win32": ++ # On Windows, 'c/escape' points outside the destination ++ self.expect_exception(tarfile.LinkOutsideDestinationError) ++ else: ++ e = self.expect_exception( ++ tarfile.LinkFallbackError, ++ "link 'boom' would be extracted as a copy of " ++ "'c/escape', which was rejected") ++ self.assertIsInstance(e.__cause__, ++ tarfile.LinkOutsideDestinationError) ++ for filter in 'tar', 'fully_trusted': ++ with self.subTest(filter), self.check_context(arc.open(), filter): ++ if not support.can_symlink(): ++ self.expect_file("a/t/dummy") ++ self.expect_file("b/") ++ self.expect_file("c/") ++ else: ++ self.expect_file("a/t/dummy") ++ self.expect_file("b/") ++ self.expect_file("a/t/escape", symlink_to='../../link_here') ++ self.expect_file("boom", symlink_to='../../link_here') ++ self.expect_file("c", symlink_to='b') ++ ++ def test_exfiltration_via_symlink(self): ++ # (CVE-2025-4138) ++ # Test changing symlinks that result in a symlink pointing outside ++ # the extraction directory, unless prevented by 'data' filter's ++ # normalization. ++ with ArchiveMaker() as arc: ++ arc.add("escape", symlink_to=os.path.join('link', 'link', '..', '..', 'link-here')) ++ arc.add("link", symlink_to='./') ++ ++ for filter in 'tar', 'data', 'fully_trusted': ++ with self.check_context(arc.open(), filter): ++ if support.can_symlink(): ++ self.expect_file("link", symlink_to='./') ++ if filter == 'data': ++ self.expect_file("escape", symlink_to='link-here') ++ else: ++ self.expect_file("escape", ++ symlink_to='link/link/../../link-here') ++ else: ++ # Nothing is extracted. ++ pass ++ ++ def test_chmod_outside_dir(self): ++ # (CVE-2024-12718) ++ # Test that members used for delayed updates of directory metadata ++ # are (re-)filtered. ++ with ArchiveMaker() as arc: ++ # "pwn" is a veeeery innocent symlink: ++ arc.add("a/pwn", symlink_to='.') ++ # But now "pwn" is also a directory, so it's scheduled to have its ++ # metadata updated later: ++ arc.add("a/pwn/", mode='drwxrwxrwx') ++ # Oops, "pwn" is not so innocent any more: ++ arc.add("a/pwn", symlink_to='x/../') ++ # Newly created symlink points to the dest dir, ++ # so it's OK for the "data" filter. ++ arc.add('a/x', symlink_to=('../')) ++ # But now "pwn" points outside the dest dir ++ ++ for filter in 'tar', 'data', 'fully_trusted': ++ with self.check_context(arc.open(), filter) as cc: ++ if not support.can_symlink(): ++ self.expect_file("a/pwn/") ++ elif filter == 'data': ++ self.expect_file("a/x", symlink_to='../') ++ self.expect_file("a/pwn", symlink_to='.') ++ else: ++ self.expect_file("a/x", symlink_to='../') ++ self.expect_file("a/pwn", symlink_to='x/../') ++ if sys.platform != "win32": ++ st_mode = cc.outerdir.stat().st_mode ++ self.assertNotEqual(st_mode & 0o777, 0o777) ++ ++ def test_link_fallback_normalizes(self): ++ # Make sure hardlink fallbacks work for non-normalized paths for all ++ # filters ++ with ArchiveMaker() as arc: ++ arc.add("dir/") ++ arc.add("dir/../afile") ++ arc.add("link1", hardlink_to='dir/../afile') ++ arc.add("link2", hardlink_to='dir/../dir/../afile') ++ ++ for filter in 'tar', 'data', 'fully_trusted': ++ with self.check_context(arc.open(), filter) as cc: ++ self.expect_file("dir/") ++ self.expect_file("afile") ++ self.expect_file("link1") ++ self.expect_file("link2") ++ + def test_modes(self): + # Test how file modes are extracted + # (Note that the modes are ignored on platforms without working chmod) +@@ -3404,7 +3813,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) +@@ -3413,7 +3822,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, '') +@@ -3542,13 +3951,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) +@@ -3559,7 +3968,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/Library/2023-08-10-17-36-22.gh-issue-107845.dABiMJ.rst b/Misc/NEWS.d/next/Library/2023-08-10-17-36-22.gh-issue-107845.dABiMJ.rst +new file mode 100644 +index 0000000000..32c1fb93f4 +--- /dev/null ++++ b/Misc/NEWS.d/next/Library/2023-08-10-17-36-22.gh-issue-107845.dABiMJ.rst +@@ -0,0 +1,3 @@ ++:func:`tarfile.data_filter` now takes the location of symlinks into account ++when determining their target, so it will no longer reject some valid ++tarballs with ``LinkOutsideDestinationError``. diff --git a/python3.6.spec b/python3.6.spec index a57ef96..1bae203 100644 --- a/python3.6.spec +++ b/python3.6.spec @@ -17,7 +17,7 @@ URL: https://www.python.org/ #global prerel ... %global upstream_version %{general_version}%{?prerel} Version: %{general_version}%{?prerel:~%{prerel}} -Release: 46%{?dist} +Release: 47%{?dist} # Python is Python # pip MIT is and bundles: # appdirs: MIT @@ -827,6 +827,27 @@ Patch452: 00452-properly-apply-exported-cflags-for-dtrace-systemtap-builds.patch # https://github.com/python/cpython/pull/127361 Patch457: 00457-ssl-raise-oserror-for-err_lib_sys.patch +# 00465 # 2224c823bcc1b62b85f516883151459ae51cdb7d +# tarfile cves +# +# Security fixes for CVE-2025-4517, CVE-2025-4330, CVE-2025-4138, CVE-2024-12718, CVE-2025-4435 on tarfile +# +# The backported fixes do not contain changes for ntpath.py and related tests, +# because the support for symlinks and junctions were added later in Python 3.9, +# and it does not make sense to backport them to 3.6 here. +# +# The patch is contains the following changes: +# - https://github.com/python/cpython/commit/42deeab5b2efc2930d4eb73416e1dde9cf790dd2 +# fixes symlink handling for tarfile.data_filter +# - https://github.com/python/cpython/commit/9d2c2a8e3b8fe18ee1568bfa4a419847b3e78575 +# fixes handling of existing files/symlinks in tarfile +# - https://github.com/python/cpython/commit/00af9794dd118f7b835dd844b2b609a503ad951e +# adds a new "strict" argument to realpath() +# - https://github.com/python/cpython/commit/dd8f187d0746da151e0025c51680979ac5b4cfb1 +# fixes mulriple CVE fixes in the tarfile module +# - downstream only fixes that makes the changes work and compatible with Python 3.6 +Patch465: 00465-tarfile-cves.patch + # (New patches go here ^^^) # # When adding new patches to "python" and "python3" in Fedora, EL, etc., @@ -2106,6 +2127,9 @@ CheckPython optimized # ====================================================== %changelog +* Thu Jun 26 2025 Lumír Balhar - 3.6.15-47 +- Security fixes for CVE-2025-4517, CVE-2025-4330, CVE-2025-4138, CVE-2024-12718, CVE-2025-4435 + * Wed Apr 23 2025 Miro Hrončok - 3.6.15-46 - Add RPM Provides for python3.6-libs, python3.6-devel, python3.6-idle, python3.6-tkinter, python3.6-test From b9056bf46d942fc9b1034c9f411a65c958620b2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miro=20Hron=C4=8Dok?= Date: Fri, 4 Jul 2025 12:58:04 +0200 Subject: [PATCH 03/10] Fixup patch 1 and 435 commit messages after the importpatches/exportpatches change This fixes up 552eb34bb57edb545b4811f9903a6317c0d74f8e --- 00001-rpath.patch | 6 +++--- 00435-gh-121650-encode-newlines-in-headers-and-verify.patch | 4 ++-- python3.6.spec | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/00001-rpath.patch b/00001-rpath.patch index 473b240..0194cdf 100644 --- a/00001-rpath.patch +++ b/00001-rpath.patch @@ -1,10 +1,10 @@ From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 From: David Malcolm Date: Wed, 13 Jan 2010 21:25:18 +0000 -Subject: =?UTF-8?q?00001:=20Fixup=20distutils/unixccompiler.py=20to=20remo?= - =?UTF-8?q?ve=20standard=20library=20path=20from=20rpath=0AWas=20Patch0=20?= - =?UTF-8?q?in=20ivazquez'=20python3000=20specfile?= +Subject: 00001: Fixup distutils/unixccompiler.py to remove standard library + path from rpath +Was Patch0 in ivazquez' python3000 specfile --- Lib/distutils/unixccompiler.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/00435-gh-121650-encode-newlines-in-headers-and-verify.patch b/00435-gh-121650-encode-newlines-in-headers-and-verify.patch index e706c63..6c6bebb 100644 --- a/00435-gh-121650-encode-newlines-in-headers-and-verify.patch +++ b/00435-gh-121650-encode-newlines-in-headers-and-verify.patch @@ -1,8 +1,8 @@ From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Hrn=C4=8Diar?= Date: Fri, 16 Aug 2024 14:12:58 +0200 -Subject: =?UTF-8?q?00435:=20gh-121650:=20Encode=20newlines=20in=20headers,?= - =?UTF-8?q?=20and=20verify=0A=20headers=20are=20sound=20(GH-122233)?= +Subject: 00435: gh-121650: Encode newlines in headers, and verify headers are + sound (GH-122233) Per RFC 2047: diff --git a/python3.6.spec b/python3.6.spec index 1bae203..b5bda08 100644 --- a/python3.6.spec +++ b/python3.6.spec @@ -340,6 +340,7 @@ Source102: setuptools-CVE-2024-6345.patch # 00001 # d06a8853cf4bae9e115f45e1d531d2dc152c5cc8 # Fixup distutils/unixccompiler.py to remove standard library path from rpath +# # Was Patch0 in ivazquez' python3000 specfile Patch1: 00001-rpath.patch @@ -739,8 +740,7 @@ Patch427: 00427-zipextfile-tell-and-seek-cve-2024-0450.patch Patch431: 00431-cve-2024-4032.patch # 00435 # f80b87e6a67eebe0693b895261bad2e9a58a4825 -# gh-121650: Encode newlines in headers, and verify -# headers are sound (GH-122233) +# gh-121650: Encode newlines in headers, and verify headers are sound (GH-122233) # # Per RFC 2047: # From abe83deb5cb84b29aac92e5c4625d285f2d25292 Mon Sep 17 00:00:00 2001 From: Fedora Release Engineering Date: Fri, 25 Jul 2025 11:19:35 +0000 Subject: [PATCH 04/10] Rebuilt for https://fedoraproject.org/wiki/Fedora_43_Mass_Rebuild --- python3.6.spec | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/python3.6.spec b/python3.6.spec index b5bda08..ee142b7 100644 --- a/python3.6.spec +++ b/python3.6.spec @@ -17,7 +17,7 @@ URL: https://www.python.org/ #global prerel ... %global upstream_version %{general_version}%{?prerel} Version: %{general_version}%{?prerel:~%{prerel}} -Release: 47%{?dist} +Release: 48%{?dist} # Python is Python # pip MIT is and bundles: # appdirs: MIT @@ -2127,6 +2127,9 @@ CheckPython optimized # ====================================================== %changelog +* Fri Jul 25 2025 Fedora Release Engineering - 3.6.15-48 +- Rebuilt for https://fedoraproject.org/wiki/Fedora_43_Mass_Rebuild + * Thu Jun 26 2025 Lumír Balhar - 3.6.15-47 - Security fixes for CVE-2025-4517, CVE-2025-4330, CVE-2025-4138, CVE-2024-12718, CVE-2025-4435 From 78ecfade396d64292930dcac6d6d30a54ca2fd6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Zachar?= Date: Mon, 28 Jul 2025 13:20:37 +0200 Subject: [PATCH 05/10] Drop STI and use tmt instead Resolve: rhbz#2383061 --- .fmf/version | 1 + plan.fmf | 32 ++++++++++++++++++++++++++++++++ tests/tests.yml | 22 ---------------------- 3 files changed, 33 insertions(+), 22 deletions(-) create mode 100644 .fmf/version create mode 100644 plan.fmf delete mode 100644 tests/tests.yml diff --git a/.fmf/version b/.fmf/version new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/.fmf/version @@ -0,0 +1 @@ +1 diff --git a/plan.fmf b/plan.fmf new file mode 100644 index 0000000..38d5410 --- /dev/null +++ b/plan.fmf @@ -0,0 +1,32 @@ +execute: + how: tmt + +discover: + - name: tests_python + how: shell + url: https://src.fedoraproject.org/tests/python.git + tests: + - name: smoke + path: /smoke + test: "VERSION=3.6 TOX_REQUIRES='virtualenv<20.22.0' ./venv.sh" + - name: marshalparser + path: /marshalparser + test: "VERSION=3.6 SAMPLE=10 ./test_marshalparser_compatibility.sh" + +prepare: + - name: Install dependencies + how: install + package: + - gcc + - python3-tox + - python3.6 + - glibc-all-langpacks # for locale tests + - marshalparser # for testing compatibility (magic numbers) with marshalparser + - dnf # for upgrade + - name: Update packages + how: shell + script: dnf upgrade -y + - name: rpm_qa + order: 100 + how: shell + script: rpm -qa | sort | tee $TMT_PLAN_DATA/rpmqa.txt diff --git a/tests/tests.yml b/tests/tests.yml deleted file mode 100644 index c6263a7..0000000 --- a/tests/tests.yml +++ /dev/null @@ -1,22 +0,0 @@ ---- -- hosts: localhost - roles: - - role: standard-test-basic - tags: - - classic - repositories: - - repo: "https://src.fedoraproject.org/tests/python.git" - dest: "python" - tests: - - smoke: - dir: python/smoke - run: VERSION=3.6 TOX_REQUIRES="virtualenv<20.22.0" ./venv.sh - - marshalparser: - dir: python/marshalparser - run: VERSION=3.6 SAMPLE=10 test_marshalparser_compatibility.sh - required_packages: - - gcc - - python3-tox - - python3.6 - - glibc-all-langpacks # for locale tests - - marshalparser # for testing compatibility (magic numbers) with marshalparser From 39181c17666c6838cbbecd0dfa4ec5050f791ca5 Mon Sep 17 00:00:00 2001 From: Lumir Balhar Date: Mon, 11 Aug 2025 13:05:03 +0200 Subject: [PATCH 06/10] Security fix for CVE-2025-8194 --- 00467-tarfile-cve-2025-8194.patch | 212 ++++++++++++++++++++++++++++++ python3.6.spec | 11 +- 2 files changed, 222 insertions(+), 1 deletion(-) create mode 100644 00467-tarfile-cve-2025-8194.patch diff --git a/00467-tarfile-cve-2025-8194.patch b/00467-tarfile-cve-2025-8194.patch new file mode 100644 index 0000000..08cef22 --- /dev/null +++ b/00467-tarfile-cve-2025-8194.patch @@ -0,0 +1,212 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Alexander Urieles +Date: Mon, 28 Jul 2025 17:37:26 +0200 +Subject: 00467: tarfile CVE-2025-8194 + +tarfile now validates archives to ensure member offsets are non-negative (GH-137027) + +Co-authored-by: Gregory P. Smith +--- + Lib/tarfile.py | 3 + + Lib/test/test_tarfile.py | 156 ++++++++++++++++++ + ...-07-23-00-35-29.gh-issue-130577.c7EITy.rst | 3 + + 3 files changed, 162 insertions(+) + create mode 100644 Misc/NEWS.d/next/Library/2025-07-23-00-35-29.gh-issue-130577.c7EITy.rst + +diff --git a/Lib/tarfile.py b/Lib/tarfile.py +index 1a7d5f772a..4f536cb002 100755 +--- a/Lib/tarfile.py ++++ b/Lib/tarfile.py +@@ -1582,6 +1582,9 @@ class TarInfo(object): + """Round up a byte count by BLOCKSIZE and return it, + e.g. _block(834) => 1024. + """ ++ # Only non-negative offsets are allowed ++ if count < 0: ++ raise InvalidHeaderError("invalid offset") + blocks, remainder = divmod(count, BLOCKSIZE) + if remainder: + blocks += 1 +diff --git a/Lib/test/test_tarfile.py b/Lib/test/test_tarfile.py +index c5d837e716..484f114180 100644 +--- a/Lib/test/test_tarfile.py ++++ b/Lib/test/test_tarfile.py +@@ -43,6 +43,7 @@ bz2name = os.path.join(TEMPDIR, "testtar.tar.bz2") + xzname = os.path.join(TEMPDIR, "testtar.tar.xz") + tmpname = os.path.join(TEMPDIR, "tmp.tar") + dotlessname = os.path.join(TEMPDIR, "testtar") ++SPACE = b" " + + md5_regtype = "65f477c818ad9e15f7feab0c6d37742f" + md5_sparse = "a54fbc4ca4f4399a90e1b27164012fc6" +@@ -4005,6 +4006,161 @@ class TestExtractionFilters(unittest.TestCase): + self.expect_exception(TypeError) # errorlevel is not int + + ++class OffsetValidationTests(unittest.TestCase): ++ tarname = tmpname ++ invalid_posix_header = ( ++ # name: 100 bytes ++ tarfile.NUL * tarfile.LENGTH_NAME ++ # mode, space, null terminator: 8 bytes ++ + b"000755" + SPACE + tarfile.NUL ++ # uid, space, null terminator: 8 bytes ++ + b"000001" + SPACE + tarfile.NUL ++ # gid, space, null terminator: 8 bytes ++ + b"000001" + SPACE + tarfile.NUL ++ # size, space: 12 bytes ++ + b"\xff" * 11 + SPACE ++ # mtime, space: 12 bytes ++ + tarfile.NUL * 11 + SPACE ++ # chksum: 8 bytes ++ + b"0011407" + tarfile.NUL ++ # type: 1 byte ++ + tarfile.REGTYPE ++ # linkname: 100 bytes ++ + tarfile.NUL * tarfile.LENGTH_LINK ++ # magic: 6 bytes, version: 2 bytes ++ + tarfile.POSIX_MAGIC ++ # uname: 32 bytes ++ + tarfile.NUL * 32 ++ # gname: 32 bytes ++ + tarfile.NUL * 32 ++ # devmajor, space, null terminator: 8 bytes ++ + tarfile.NUL * 6 + SPACE + tarfile.NUL ++ # devminor, space, null terminator: 8 bytes ++ + tarfile.NUL * 6 + SPACE + tarfile.NUL ++ # prefix: 155 bytes ++ + tarfile.NUL * tarfile.LENGTH_PREFIX ++ # padding: 12 bytes ++ + tarfile.NUL * 12 ++ ) ++ invalid_gnu_header = ( ++ # name: 100 bytes ++ tarfile.NUL * tarfile.LENGTH_NAME ++ # mode, null terminator: 8 bytes ++ + b"0000755" + tarfile.NUL ++ # uid, null terminator: 8 bytes ++ + b"0000001" + tarfile.NUL ++ # gid, space, null terminator: 8 bytes ++ + b"0000001" + tarfile.NUL ++ # size, space: 12 bytes ++ + b"\xff" * 11 + SPACE ++ # mtime, space: 12 bytes ++ + tarfile.NUL * 11 + SPACE ++ # chksum: 8 bytes ++ + b"0011327" + tarfile.NUL ++ # type: 1 byte ++ + tarfile.REGTYPE ++ # linkname: 100 bytes ++ + tarfile.NUL * tarfile.LENGTH_LINK ++ # magic: 8 bytes ++ + tarfile.GNU_MAGIC ++ # uname: 32 bytes ++ + tarfile.NUL * 32 ++ # gname: 32 bytes ++ + tarfile.NUL * 32 ++ # devmajor, null terminator: 8 bytes ++ + tarfile.NUL * 8 ++ # devminor, null terminator: 8 bytes ++ + tarfile.NUL * 8 ++ # padding: 167 bytes ++ + tarfile.NUL * 167 ++ ) ++ invalid_v7_header = ( ++ # name: 100 bytes ++ tarfile.NUL * tarfile.LENGTH_NAME ++ # mode, space, null terminator: 8 bytes ++ + b"000755" + SPACE + tarfile.NUL ++ # uid, space, null terminator: 8 bytes ++ + b"000001" + SPACE + tarfile.NUL ++ # gid, space, null terminator: 8 bytes ++ + b"000001" + SPACE + tarfile.NUL ++ # size, space: 12 bytes ++ + b"\xff" * 11 + SPACE ++ # mtime, space: 12 bytes ++ + tarfile.NUL * 11 + SPACE ++ # chksum: 8 bytes ++ + b"0010070" + tarfile.NUL ++ # type: 1 byte ++ + tarfile.REGTYPE ++ # linkname: 100 bytes ++ + tarfile.NUL * tarfile.LENGTH_LINK ++ # padding: 255 bytes ++ + tarfile.NUL * 255 ++ ) ++ valid_gnu_header = tarfile.TarInfo("filename").tobuf(tarfile.GNU_FORMAT) ++ data_block = b"\xff" * tarfile.BLOCKSIZE ++ ++ def _write_buffer(self, buffer): ++ with open(self.tarname, "wb") as f: ++ f.write(buffer) ++ ++ def _get_members(self, ignore_zeros=None): ++ with open(self.tarname, "rb") as f: ++ with tarfile.open( ++ mode="r", fileobj=f, ignore_zeros=ignore_zeros ++ ) as tar: ++ return tar.getmembers() ++ ++ def _assert_raises_read_error_exception(self): ++ with self.assertRaisesRegex( ++ tarfile.ReadError, "file could not be opened successfully" ++ ): ++ self._get_members() ++ ++ def test_invalid_offset_header_validations(self): ++ for tar_format, invalid_header in ( ++ ("posix", self.invalid_posix_header), ++ ("gnu", self.invalid_gnu_header), ++ ("v7", self.invalid_v7_header), ++ ): ++ with self.subTest(format=tar_format): ++ self._write_buffer(invalid_header) ++ self._assert_raises_read_error_exception() ++ ++ def test_early_stop_at_invalid_offset_header(self): ++ buffer = self.valid_gnu_header + self.invalid_gnu_header + self.valid_gnu_header ++ self._write_buffer(buffer) ++ members = self._get_members() ++ self.assertEqual(len(members), 1) ++ self.assertEqual(members[0].name, "filename") ++ self.assertEqual(members[0].offset, 0) ++ ++ def test_ignore_invalid_archive(self): ++ # 3 invalid headers with their respective data ++ buffer = (self.invalid_gnu_header + self.data_block) * 3 ++ self._write_buffer(buffer) ++ members = self._get_members(ignore_zeros=True) ++ self.assertEqual(len(members), 0) ++ ++ def test_ignore_invalid_offset_headers(self): ++ for first_block, second_block, expected_offset in ( ++ ( ++ (self.valid_gnu_header), ++ (self.invalid_gnu_header + self.data_block), ++ 0, ++ ), ++ ( ++ (self.invalid_gnu_header + self.data_block), ++ (self.valid_gnu_header), ++ 1024, ++ ), ++ ): ++ self._write_buffer(first_block + second_block) ++ members = self._get_members(ignore_zeros=True) ++ self.assertEqual(len(members), 1) ++ self.assertEqual(members[0].name, "filename") ++ self.assertEqual(members[0].offset, expected_offset) ++ ++ + def setUpModule(): + support.unlink(TEMPDIR) + os.makedirs(TEMPDIR) +diff --git a/Misc/NEWS.d/next/Library/2025-07-23-00-35-29.gh-issue-130577.c7EITy.rst b/Misc/NEWS.d/next/Library/2025-07-23-00-35-29.gh-issue-130577.c7EITy.rst +new file mode 100644 +index 0000000000..342cabbc86 +--- /dev/null ++++ b/Misc/NEWS.d/next/Library/2025-07-23-00-35-29.gh-issue-130577.c7EITy.rst +@@ -0,0 +1,3 @@ ++:mod:`tarfile` now validates archives to ensure member offsets are ++non-negative. (Contributed by Alexander Enrique Urieles Nieto in ++:gh:`130577`.) diff --git a/python3.6.spec b/python3.6.spec index ee142b7..b4ed7e4 100644 --- a/python3.6.spec +++ b/python3.6.spec @@ -17,7 +17,7 @@ URL: https://www.python.org/ #global prerel ... %global upstream_version %{general_version}%{?prerel} Version: %{general_version}%{?prerel:~%{prerel}} -Release: 48%{?dist} +Release: 49%{?dist} # Python is Python # pip MIT is and bundles: # appdirs: MIT @@ -848,6 +848,12 @@ Patch457: 00457-ssl-raise-oserror-for-err_lib_sys.patch # - downstream only fixes that makes the changes work and compatible with Python 3.6 Patch465: 00465-tarfile-cves.patch +# 00467 # f0b2819ec35fe1f732f661aea68863a5e4dd829f +# tarfile CVE-2025-8194 +# +# tarfile now validates archives to ensure member offsets are non-negative (GH-137027) +Patch467: 00467-tarfile-cve-2025-8194.patch + # (New patches go here ^^^) # # When adding new patches to "python" and "python3" in Fedora, EL, etc., @@ -2127,6 +2133,9 @@ CheckPython optimized # ====================================================== %changelog +* Mon Aug 11 2025 Lumír Balhar - 3.6.15-49 +- Security fix for CVE-2025-8194 + * Fri Jul 25 2025 Fedora Release Engineering - 3.6.15-48 - Rebuilt for https://fedoraproject.org/wiki/Fedora_43_Mass_Rebuild From 5d945b7395f2b3fee800e4596384d7d3caa8262c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miro=20Hron=C4=8Dok?= Date: Mon, 1 Sep 2025 13:10:15 +0200 Subject: [PATCH 07/10] Inject SBOM into the installed wheels --- python3.6.spec | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/python3.6.spec b/python3.6.spec index b4ed7e4..9ef03cf 100644 --- a/python3.6.spec +++ b/python3.6.spec @@ -302,6 +302,9 @@ BuildRequires: /usr/sbin/ifconfig %if %{with rpmwheels} BuildRequires: python-setuptools-wheel BuildRequires: python-pip-wheel +%else +# For %%python_wheel_inject_sbom +BuildRequires: python-rpm-macros %endif @@ -1563,6 +1566,11 @@ ln -s \ %{buildroot}%{_bindir}/python3-debug %endif +%if %{without rpmwheels} +# Inject SBOM into the installed wheels (if the macro is available) +%{?python_wheel_inject_sbom:%python_wheel_inject_sbom %{buildroot}%{pylibdir}/ensurepip/_bundled/*.whl} +%endif + %if %{with flatpackage} # Remove stuff that would conflict with python3 package rm %{buildroot}%{_bindir}/python3 From 7b13c3ecf0b5b41619811d3598b9c0a4daa11af0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miro=20Hron=C4=8Dok?= Date: Thu, 6 Nov 2025 13:08:18 +0100 Subject: [PATCH 08/10] On Fedora 44+, split this package into multiple subpackages python3.9 and python3.6 were the only remaining Pythons that are "flatpackaged". As they are likely to stay around for a while, it makes no sense to wait this out (like we did with e.g. 3.7 and 3.8). This (together with similar change in python3.9) will make all the packaged Pythons packaged in the same way. Additionally, this also enables the debug build, which was added to CI. See https://src.fedoraproject.org/rpms/python3.9/c/7a81f94ccc Assisted-By: Cursor 2.0.38 with gpt-5 --- plan.fmf | 12 +++++-- python3.6.spec | 98 ++++++++++++++++++++++++++++++-------------------- 2 files changed, 69 insertions(+), 41 deletions(-) diff --git a/plan.fmf b/plan.fmf index 38d5410..8639a8d 100644 --- a/plan.fmf +++ b/plan.fmf @@ -1,6 +1,9 @@ execute: how: tmt +environment: + pybasever: '3.6' + discover: - name: tests_python how: shell @@ -8,10 +11,13 @@ discover: tests: - name: smoke path: /smoke - test: "VERSION=3.6 TOX_REQUIRES='virtualenv<20.22.0' ./venv.sh" + test: "VERSION=${pybasever} TOX_REQUIRES='virtualenv<20.22.0' ./venv.sh" + - name: debugsmoke + path: /smoke + test: "PYTHON=python${pybasever}dm TOX=false VERSION=${pybasever} INSTALL_OR_SKIP=true ./venv.sh" - name: marshalparser path: /marshalparser - test: "VERSION=3.6 SAMPLE=10 ./test_marshalparser_compatibility.sh" + test: "VERSION=${pybasever} SAMPLE=10 ./test_marshalparser_compatibility.sh" prepare: - name: Install dependencies @@ -19,7 +25,7 @@ prepare: package: - gcc - python3-tox - - python3.6 + - python${pybasever} - glibc-all-langpacks # for locale tests - marshalparser # for testing compatibility (magic numbers) with marshalparser - dnf # for upgrade diff --git a/python3.6.spec b/python3.6.spec index 9ef03cf..4dcb391 100644 --- a/python3.6.spec +++ b/python3.6.spec @@ -17,7 +17,7 @@ URL: https://www.python.org/ #global prerel ... %global upstream_version %{general_version}%{?prerel} Version: %{general_version}%{?prerel:~%{prerel}} -Release: 49%{?dist} +Release: 50%{?dist} # Python is Python # pip MIT is and bundles: # appdirs: MIT @@ -59,12 +59,22 @@ License: LicenseRef-Callaway-Python AND LicenseRef-Callaway-MIT AND Apache-2.0 A # Note that the bcond macros are named for the CLI option they create. # "%%bcond_without" means "ENABLE by default and create a --without option" +# Main Python, i.e. whether this is the main Python version in the distribution +# that owns /usr/bin/python3 and other unique paths +# This also means the built subpackages are called python3 rather than python3X +# WARNING: This also influences the flatpackage bcond below. +# By default, this is disabled. +%bcond_with main_python + # Flat package, i.e. python36, python37, python38 for tox etc. -# warning: changes some other defaults -# in Fedora, never turn this on for the python3 package -# and always keep it on for python36 etc. -# WARNING: This does not change the package name and summary above +# Default (in Fedora >= 44): disabled +# Default (in Fedora < 44): enabled when this is not the main Python +# Not supported: Combination of flatpackage enabled and main_python enabled +%if %{with main_python} || 0%{?fedora} >= 44 +%bcond_with flatpackage +%else %bcond_without flatpackage +%endif # Whether to use RPM build wheels from the python-{pip,setuptools}-wheel package # Uses upstream bundled prebuilt wheels otherwise @@ -875,12 +885,16 @@ Patch467: 00467-tarfile-cve-2025-8194.patch Provides: python%{pyshortver} = %{version}-%{release} Obsoletes: python%{pyshortver} < %{version}-%{release} -%if %{without flatpackage} - # Packages with Python modules in standard locations automatically -# depend on python(abi). Provide that here. +# depend on python(abi). Provide that here only for the main Python. +%if %{with main_python} Provides: python(abi) = %{pybasever} +%else +%global __requires_exclude ^python\\(abi\\) = 3\\..+ +%global __provides_exclude ^python\\(abi\\) = 3\\..+ +%endif +%if %{without flatpackage} Requires: %{name}-libs%{?_isa} = %{version}-%{release} # In order to support multiple Python interpreters for development purposes, @@ -895,18 +909,16 @@ Provides: python%{pyshortver} = %{version}-%{release} # replace python36-3.6.2. Obsoletes: python%{pyshortver} -# Shall be removed in Fedora 31 -# The release is bumped to 20, so we can do f27 platform-python updates -# If the release in f27 ever goes >= 20, raise it here -# If platform-python is ever reintroduced, make it higher version than this: -%global platpyver 3.6.2-20 -Obsoletes: platform-python < %{platpyver} +# https://docs.fedoraproject.org/en-US/packaging-guidelines/#_one_to_many_replacement +Obsoletes: %{name} < 3.6.15-50 +%if %{with main_python} # Previously, this was required for our rewheel patch to work. # This is technically no longer needed, but we keep it recommended # for the developer experience. Recommends: python3-setuptools Recommends: python3-pip +%endif # This prevents ALL subpackages built from this spec to require # /usr/bin/python3*. Granularity per subpackage is impossible. @@ -989,9 +1001,8 @@ Provides: bundled(libmpdec) = %{libmpdec_version} # See https://bugzilla.redhat.com/show_bug.cgi?id=1547131 Recommends: %{name}%{?_isa} = %{version}-%{release} -# Shall be removed in Fedora 31 -Obsoletes: platform-python-libs < %{platpyver} -Obsoletes: platform-python-libs-devel < %{platpyver} +# https://docs.fedoraproject.org/en-US/packaging-guidelines/#_one_to_many_replacement +Obsoletes: %{name} < 3.6.15-50 %description libs This package contains runtime libraries for use by Python: @@ -1010,12 +1021,14 @@ Requires: python3-rpm-macros Requires: python3-rpm-generators Provides: %{name}-2to3 = %{version}-%{release} +%if %{with main_python} Provides: 2to3 = %{version}-%{release} +%endif Conflicts: %{name} < %{version}-%{release} -# Shall be removed in Fedora 31 -Obsoletes: platform-python-devel < %{platpyver} +# https://docs.fedoraproject.org/en-US/packaging-guidelines/#_one_to_many_replacement +Obsoletes: %{name} < 3.6.15-50 %description devel This package contains the header files and configuration needed to compile @@ -1031,14 +1044,16 @@ Summary: A basic graphical development environment for Python Requires: %{name} = %{version}-%{release} Requires: %{name}-tkinter = %{version}-%{release} +%if %{with main_python} Provides: idle3 = %{version}-%{release} +%endif Provides: %{name}-tools = %{version}-%{release} Provides: %{name}-tools%{?_isa} = %{version}-%{release} Obsoletes: %{name}-tools < %{version}-%{release} -# Shall be removed in Fedora 31 -Obsoletes: platform-python-tools < %{platpyver} +# https://docs.fedoraproject.org/en-US/packaging-guidelines/#_one_to_many_replacement +Obsoletes: %{name} < 3.6.15-50 %description idle IDLE is Python’s Integrated Development and Learning Environment. @@ -1057,8 +1072,8 @@ configuration, browsers, and other dialogs. Summary: A GUI toolkit for Python Requires: %{name} = %{version}-%{release} -# Shall be removed in Fedora 31 -Obsoletes: platform-python-tkinter < %{platpyver} +# https://docs.fedoraproject.org/en-US/packaging-guidelines/#_one_to_many_replacement +Obsoletes: %{name} < 3.6.15-50 %description tkinter The Tkinter (Tk interface) library is a graphical user interface toolkit for @@ -1069,8 +1084,8 @@ the Python programming language. Summary: The self-test suite for the main python3 package Requires: %{name} = %{version}-%{release} -# Shall be removed in Fedora 31 -Obsoletes: platform-python-test < %{platpyver} +# https://docs.fedoraproject.org/en-US/packaging-guidelines/#_one_to_many_replacement +Obsoletes: %{name} < 3.6.15-50 %description test The self-test suite for the Python interpreter. @@ -1115,11 +1130,6 @@ so extensions for both versions can co-exist in the same directory. %else # with flatpackage -# We'll not provide this, on purpose -# No package in Fedora shall ever depend on flatpackage via this -%global __requires_exclude ^python\\(abi\\) = 3\\..$ -%global __provides_exclude ^python\\(abi\\) = 3\\..$ - %if %{with rpmwheels} Requires: python-setuptools-wheel Requires: python-pip-wheel @@ -1470,7 +1480,7 @@ install -d -m 0755 %{buildroot}%{pylibdir}/site-packages/__pycache__ install -d -m 0755 %{buildroot}%{_prefix}/lib/python%{pybasever}/site-packages/__pycache__ %endif -%if %{without flatpackage} +%if %{with main_python} # add idle3 to menu install -D -m 0644 Lib/idlelib/Icons/idle_16.png %{buildroot}%{_datadir}/icons/hicolor/16x16/apps/idle3.png install -D -m 0644 Lib/idlelib/Icons/idle_32.png %{buildroot}%{_datadir}/icons/hicolor/32x32/apps/idle3.png @@ -1560,7 +1570,7 @@ find %{buildroot} -perm 555 -exec chmod 755 {} \; # Create "/usr/bin/python3-debug", a symlink to the python3 debug binary, to # avoid the user having to know the precise version and ABI flags. # See e.g. https://bugzilla.redhat.com/show_bug.cgi?id=676748 -%if %{with debug_build} && %{without flatpackage} +%if %{with debug_build} && %{with main_python} ln -s \ %{_bindir}/python%{LDVERSION_debug} \ %{buildroot}%{_bindir}/python3-debug @@ -1571,7 +1581,7 @@ ln -s \ %{?python_wheel_inject_sbom:%python_wheel_inject_sbom %{buildroot}%{pylibdir}/ensurepip/_bundled/*.whl} %endif -%if %{with flatpackage} +%if %{without main_python} # Remove stuff that would conflict with python3 package rm %{buildroot}%{_bindir}/python3 rm %{buildroot}%{_bindir}/pydoc3 @@ -1688,11 +1698,15 @@ CheckPython optimized %doc README.rst %if %{without flatpackage} +%if %{with main_python} %{_bindir}/pydoc* %{_bindir}/python3 %{_bindir}/pyvenv %{_mandir}/*/* -%{_bindir}/pyvenv +%else +%{_bindir}/pydoc%{pybasever} +%{_mandir}/*/python%{pybasever}* +%endif %else %{_bindir}/pydoc%{pybasever} %{_mandir}/*/python%{pybasever}* @@ -1920,27 +1934,29 @@ CheckPython optimized %{_includedir}/python%{LDVERSION_optimized}/%{_pyconfig_h} %{_libdir}/%{py_INSTSONAME_optimized} -%if %{without flatpackage} +%if %{with main_python} %{_libdir}/libpython3.so %endif %if %{without flatpackage} %files devel +%if %{with main_python} %{_bindir}/2to3 # TODO: Remove 2to3-3.7 once rebased to 3.7 %{_bindir}/2to3-%{pybasever} %endif +%endif %{pylibdir}/config-%{LDVERSION_optimized}-%{platform_triplet}/* %if %{without flatpackage} %exclude %{pylibdir}/config-%{LDVERSION_optimized}-%{platform_triplet}/Makefile %exclude %{_includedir}/python%{LDVERSION_optimized}/%{_pyconfig_h} %endif -%{pylibdir}/distutils/command/wininst-*.exe +%exclude %{pylibdir}/distutils/command/wininst-*.exe %{_includedir}/python%{LDVERSION_optimized}/*.h %doc Misc/README.valgrind Misc/valgrind-python.supp Misc/gdbinit -%if %{without flatpackage} +%if %{with main_python} %{_bindir}/python3-config %{_libdir}/pkgconfig/python3.pc %{_bindir}/pathfix.py @@ -1968,7 +1984,7 @@ CheckPython optimized %{pylibdir}/idlelib -%if %{without flatpackage} +%if %{with main_python} %{_metainfodir}/idle3.appdata.xml %{_datadir}/applications/idle3.desktop %{_datadir}/icons/hicolor/*/apps/idle3.* @@ -2016,8 +2032,10 @@ CheckPython optimized %if %{with debug_build} %if %{without flatpackage} %files debug +%if %{with main_python} %{_bindir}/python3-debug %endif +%endif # Analog of the core subpackage's files: %{_bindir}/python%{LDVERSION_debug} @@ -2141,6 +2159,10 @@ CheckPython optimized # ====================================================== %changelog +* Thu Nov 06 2025 Miro Hrončok - 3.6.15-50 +- On Fedora 44+, split this package into multiple subpackages +- This mimics newer Python versions + * Mon Aug 11 2025 Lumír Balhar - 3.6.15-49 - Security fix for CVE-2025-8194 From c03d34a103ea91e38bf6d7689013c7cd164a6568 Mon Sep 17 00:00:00 2001 From: Tomas Orsava Date: Fri, 29 Apr 2022 10:24:01 +0000 Subject: [PATCH 09/10] Move _sysconfigdata_dm_linux*.py to the debug subpackage (cherry picked from python3.10 commit 55d25b67d6153038b462d312e40c083d965ed5dc) --- python3.6.spec | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/python3.6.spec b/python3.6.spec index 4dcb391..c9217a2 100644 --- a/python3.6.spec +++ b/python3.6.spec @@ -1851,6 +1851,10 @@ CheckPython optimized %dir %{pylibdir}/site-packages/ %dir %{pylibdir}/site-packages/__pycache__/ %{pylibdir}/site-packages/README.txt + +%exclude %{pylibdir}/_sysconfigdata_%{ABIFLAGS_debug}_linux_%{platform_triplet}.py +%exclude %{pylibdir}/__pycache__/_sysconfigdata_%{ABIFLAGS_debug}_linux_%{platform_triplet}%{bytecode_suffixes} + %{pylibdir}/*.py %dir %{pylibdir}/__pycache__/ %{pylibdir}/__pycache__/*%{bytecode_suffixes} @@ -2108,6 +2112,9 @@ CheckPython optimized %{dynload_dir}/unicodedata.%{SOABI_debug}.so %{dynload_dir}/zlib.%{SOABI_debug}.so +%{pylibdir}/_sysconfigdata_%{ABIFLAGS_debug}_linux_%{platform_triplet}.py +%{pylibdir}/__pycache__/_sysconfigdata_%{ABIFLAGS_debug}_linux_%{platform_triplet}%{bytecode_suffixes} + # No need to split things out the "Makefile" and the config-32/64.h file as we # do for the regular build above (bug 531901), since they're all in one package # now; they're listed below, under "-devel": From b8f7f2f8f17ea27e06ece70132c99d8eca2af2bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miro=20Hron=C4=8Dok?= Date: Sun, 11 Aug 2019 11:23:07 +0200 Subject: [PATCH 10/10] Conditionalize python3-devel runtime dependencies on RPM packages Installing python3-devel currently brings in many RPM-related packages. For somebody who needs python3-devel to build extension modules, those are all useless, so we conditionalize it on rpm-build. (cherry picked from commit e9ba67b74b9849d00cfc8e332b711db142dfd3e3) --- python3.6.spec | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/python3.6.spec b/python3.6.spec index c9217a2..190964f 100644 --- a/python3.6.spec +++ b/python3.6.spec @@ -1016,9 +1016,11 @@ Summary: Libraries and header files needed for Python development Requires: %{name} = %{version}-%{release} Requires: %{name}-libs%{?_isa} = %{version}-%{release} BuildRequires: python-rpm-macros -Requires: python-rpm-macros -Requires: python3-rpm-macros -Requires: python3-rpm-generators +# The RPM related dependencies bring nothing to a non-RPM Python developer +# But we want them when packages BuildRequire python3-devel +Requires: (python-rpm-macros if rpm-build) +Requires: (python3-rpm-macros if rpm-build) +Requires: (python3-rpm-generators if rpm-build) Provides: %{name}-2to3 = %{version}-%{release} %if %{with main_python}