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/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/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/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/plan.fmf b/plan.fmf new file mode 100644 index 0000000..8639a8d --- /dev/null +++ b/plan.fmf @@ -0,0 +1,38 @@ +execute: + how: tmt + +environment: + pybasever: '3.6' + +discover: + - name: tests_python + how: shell + url: https://src.fedoraproject.org/tests/python.git + tests: + - name: smoke + path: /smoke + 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=${pybasever} SAMPLE=10 ./test_marshalparser_compatibility.sh" + +prepare: + - name: Install dependencies + how: install + package: + - gcc + - python3-tox + - python${pybasever} + - 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/python3.6.spec b/python3.6.spec index 35c5489..190964f 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: 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 @@ -161,6 +171,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 @@ -300,6 +312,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 @@ -338,6 +353,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 @@ -737,8 +753,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: # @@ -825,6 +840,33 @@ 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 + +# 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., @@ -843,12 +885,16 @@ Patch457: 00457-ssl-raise-oserror-for-err_lib_sys.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, @@ -863,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. @@ -957,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: @@ -973,17 +1016,21 @@ 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} 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 @@ -999,14 +1046,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. @@ -1025,8 +1074,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 @@ -1037,8 +1086,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. @@ -1083,11 +1132,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 @@ -1131,6 +1175,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. @@ -1428,7 +1482,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 @@ -1518,13 +1572,18 @@ 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 %endif -%if %{with flatpackage} +%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 %{without main_python} # Remove stuff that would conflict with python3 package rm %{buildroot}%{_bindir}/python3 rm %{buildroot}%{_bindir}/pydoc3 @@ -1641,11 +1700,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}* @@ -1790,6 +1853,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} @@ -1873,27 +1940,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 @@ -1921,7 +1990,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.* @@ -1969,8 +2038,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} @@ -2043,6 +2114,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": @@ -2094,6 +2168,22 @@ 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 + +* 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 + +* 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 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