From 2f6b3800e6004b97e04082c9fa06192ae37aea52 Mon Sep 17 00:00:00 2001 From: Diego Russo Date: Fri, 8 May 2026 12:58:48 +0100 Subject: [PATCH 1/5] Add Diego as author of PEP 831 (#149551) --- Doc/whatsnew/3.15.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index 9e2f789334ff02..0f7782ba1813d1 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -411,8 +411,8 @@ embedding applications, and native libraries. unwinding for the whole Python process. (Contributed by Pablo Galindo Salgado and Savannah Ostrowski in -:gh:`149201`; PEP 831 written by Pablo Galindo Salgado, Ken Jin, and -Savannah Ostrowski.) +:gh:`149201`; PEP 831 written by Pablo Galindo Salgado, Ken Jin, +Savannah Ostrowski, and Diego Russo.) .. seealso:: :pep:`831` for further details. From 578411982c16f753f4893532510099ef665117da Mon Sep 17 00:00:00 2001 From: Petr Viktorin Date: Fri, 8 May 2026 14:16:06 +0200 Subject: [PATCH 2/5] gh-149486: tarfile.data_filter: validate written link target (GH-149487) The data filter rewrote linknames with normpath() but ran the containment check against the un-normalised value, and computed a symlink's directory before stripping trailing slashes. Both let a crafted archive create links pointing outside the destination. Also reject link members that resolve to the destination directory itself, which could otherwise replace it with a symlink and redirect all subsequent members. (Patch by Greg; Petr's just reviewing & merging.) Co-authored-by: Gregory P. Smith --- Lib/tarfile.py | 16 ++-- Lib/test/test_tarfile.py | 87 ++++++++++++++++++- ...-05-03-21-00-00.gh-issue-149486.tarflt.rst | 5 ++ 3 files changed, 99 insertions(+), 9 deletions(-) create mode 100644 Misc/NEWS.d/next/Security/2026-05-03-21-00-00.gh-issue-149486.tarflt.rst diff --git a/Lib/tarfile.py b/Lib/tarfile.py index d0e7dec5575047..1394a26f2096ff 100644 --- a/Lib/tarfile.py +++ b/Lib/tarfile.py @@ -830,16 +830,22 @@ 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) + # A link member that resolves to the destination directory itself + # would replace it with a (sym)link, redirecting the destination + # for all subsequent members. + if target_path == dest_path: + raise OutsideDestinationError(member, target_path) 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) + # The symlink is created at `name` with trailing separators + # stripped, so its target is relative to the directory + # containing that path. + link_dir = os.path.dirname(name.rstrip('/' + os.sep)) + target_path = os.path.join(dest_path, link_dir, normalized) else: - target_path = os.path.join(dest_path, - member.linkname) + target_path = os.path.join(dest_path, normalized) target_path = os.path.realpath(target_path, strict=os.path.ALLOW_MISSING) if os.path.commonpath([target_path, dest_path]) != dest_path: diff --git a/Lib/test/test_tarfile.py b/Lib/test/test_tarfile.py index e270cbb22e2d1a..192c948edc6056 100644 --- a/Lib/test/test_tarfile.py +++ b/Lib/test/test_tarfile.py @@ -3911,10 +3911,19 @@ def test_parent_symlink(self): + "which is outside the destination") with self.check_context(arc.open(), 'data'): - self.expect_exception( - tarfile.LinkOutsideDestinationError, - """'parent' would link to ['"].*outerdir['"], """ - + "which is outside the destination") + if self.dotdot_resolves_early: + # 'current/../..' normalises to '..', which is rejected. + self.expect_exception( + tarfile.LinkOutsideDestinationError, + """'parent' would link to ['"].*outerdir['"], """ + + "which is outside the destination") + else: + # 'current/..' normalises to '.'; the rewritten link is + # created and 'parent/evil' lands harmlessly inside the + # destination. + self.expect_file('current', symlink_to='.') + self.expect_file('parent', symlink_to='.') + self.expect_file('evil') else: # No symlink support. The symlinks are ignored. @@ -4174,6 +4183,76 @@ def test_sly_relative2(self): + """['"].*moo['"], which is outside the """ + "destination") + @symlink_test + @os_helper.skip_unless_symlink + def test_normpath_realpath_mismatch(self): + # The link-target check must validate the value that will actually + # be written to disk (the normalised linkname), not the original. + # Here 'a' is a symlink to a deep nonexistent path, so realpath() + # of 'a/../../...' stays inside the destination while normpath() + # collapses 'a/..' lexically and escapes. + depth = len(self.destdir.parts) + 5 + deep = '/'.join(f'p{i}' for i in range(depth)) + sneaky = 'a/' + '../' * depth + 'flag' + for kind in 'symlink_to', 'hardlink_to': + with self.subTest(kind): + with ArchiveMaker() as arc: + arc.add('a', symlink_to=deep) + arc.add('escape', **{kind: sneaky}) + with self.check_context(arc.open(), 'data'): + self.expect_exception( + tarfile.LinkOutsideDestinationError) + + @symlink_test + @os_helper.skip_unless_symlink + def test_symlink_trailing_slash(self): + # A trailing slash on a symlink member's name must not cause the + # link target to be resolved relative to the wrong directory. + with ArchiveMaker() as arc: + t = tarfile.TarInfo('x/') + t.type = tarfile.SYMTYPE + t.linkname = '..' + arc.tar_w.addfile(t) + arc.add('x/escaped', content='hi') + + with self.check_context(arc.open(), 'data'): + self.expect_exception(tarfile.LinkOutsideDestinationError) + + @symlink_test + @os_helper.skip_unless_symlink + def test_link_at_destination(self): + # A link member whose name resolves to the destination directory + # itself must be rejected: otherwise the destination is replaced + # by a symlink and later members can be redirected through it. + for name in '', '.', './': + with ArchiveMaker() as arc: + t = tarfile.TarInfo(name) + t.type = tarfile.SYMTYPE + t.linkname = '.' + arc.tar_w.addfile(t) + + with self.check_context(arc.open(), 'data'): + self.expect_exception(tarfile.OutsideDestinationError) + + @symlink_test + @os_helper.skip_unless_symlink + def test_empty_name_symlink_chain(self): + # Regression test for a chain of empty-named symlinks that + # incrementally redirects the destination outwards. + with ArchiveMaker() as arc: + for name, target in [('', ''), ('a/', '..'), + ('', 'dummy'), ('', 'a'), + ('b/', '..'), + ('', 'dummy'), ('', 'a/b')]: + t = tarfile.TarInfo(name) + t.type = tarfile.SYMTYPE + t.linkname = target + arc.tar_w.addfile(t) + arc.add('escaped', content='hi') + + with self.check_context(arc.open(), 'data'): + self.expect_exception(tarfile.FilterError) + @symlink_test def test_deep_symlink(self): # Test that symlinks and hardlinks inside a directory diff --git a/Misc/NEWS.d/next/Security/2026-05-03-21-00-00.gh-issue-149486.tarflt.rst b/Misc/NEWS.d/next/Security/2026-05-03-21-00-00.gh-issue-149486.tarflt.rst new file mode 100644 index 00000000000000..7c69edb683cf80 --- /dev/null +++ b/Misc/NEWS.d/next/Security/2026-05-03-21-00-00.gh-issue-149486.tarflt.rst @@ -0,0 +1,5 @@ +:func:`tarfile.data_filter` now validates link targets using the same +normalised value that is written to disk, strips trailing separators from +the member name when resolving a symlink's directory, and rejects link +members that would replace the destination directory itself. This closes +several path-traversal bypasses of the ``data`` extraction filter. From 2a8cece95cd6abe08ae7a308f2815d07dfe8d5ad Mon Sep 17 00:00:00 2001 From: Diego Russo Date: Fri, 8 May 2026 14:03:05 +0100 Subject: [PATCH 3/5] Skip GNU backtrace test on Arm 32-bit (#149493) --- Lib/test/test_frame_pointer_unwind.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/Lib/test/test_frame_pointer_unwind.py b/Lib/test/test_frame_pointer_unwind.py index faa012c9c00d8f..1cf5083fd0fdcf 100644 --- a/Lib/test/test_frame_pointer_unwind.py +++ b/Lib/test/test_frame_pointer_unwind.py @@ -89,6 +89,21 @@ def _frame_pointers_expected(machine): return None +def _is_arm32_build(): + if sys.maxsize >= 2**32: + return False + + abi = " ".join( + value for value in ( + sysconfig.get_config_var("MULTIARCH"), + sysconfig.get_config_var("HOST_GNU_TYPE"), + sysconfig.get_config_var("SOABI"), + ) + if value + ).lower() + return "arm" in abi + + def _build_stack_and_unwind(unwinder): import operator @@ -295,6 +310,10 @@ def test_manual_unwind_respects_frame_pointers(self): @support.requires_gil_enabled("test requires the GIL enabled") @unittest.skipIf(support.is_wasi, "test not supported on WASI") @unittest.skipUnless(sys.platform == "linux", "GNU backtrace unwinding test requires Linux") +@unittest.skipIf( + _is_arm32_build(), + "GNU backtrace unwinding skipped on Arm 32-bit", +) class GnuBacktraceUnwindTests(unittest.TestCase): def setUp(self): From ebf6d9c3e2a4d924a5c7f6ffb6f7d68f79a85c8d Mon Sep 17 00:00:00 2001 From: Diego Russo Date: Fri, 8 May 2026 14:58:38 +0100 Subject: [PATCH 4/5] Rename fp unwind test module to C stack unwind (#149563) --- ...inter_unwind.py => test_c_stack_unwind.py} | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) rename Lib/test/{test_frame_pointer_unwind.py => test_c_stack_unwind.py} (92%) diff --git a/Lib/test/test_frame_pointer_unwind.py b/Lib/test/test_c_stack_unwind.py similarity index 92% rename from Lib/test/test_frame_pointer_unwind.py rename to Lib/test/test_c_stack_unwind.py index 1cf5083fd0fdcf..91bf44e463473d 100644 --- a/Lib/test/test_frame_pointer_unwind.py +++ b/Lib/test/test_c_stack_unwind.py @@ -1,3 +1,12 @@ +"""Test in-process C stack unwinders against Python and JIT frames. + +The tests build a recursive Python call stack, ask each _testinternalcapi +unwinder for return addresses, and classify those addresses as Python, JIT, or +other frames. The backends include CPython's manual stack-chain unwinder and +GNU backtrace(), so this module is about in-process C stack unwinding rather +than a single unwind mechanism. GDB integration tests live in test_gdb. +""" + import json import os import platform @@ -20,7 +29,7 @@ STACK_DEPTH = 10 -def _frame_pointers_expected(machine): +def _manual_unwind_expected(machine): _Py_WITH_FRAME_POINTERS = getattr( _testinternalcapi, "_Py_WITH_FRAME_POINTERS", @@ -195,7 +204,7 @@ def _annotate_unwind_after_executor_free(unwinder_name="gnu_backtrace_unwind"): def _run_unwind_helper(helper_name, unwinder_name, **env): code = ( - f"from test.test_frame_pointer_unwind import {helper_name}; " + f"from test.test_c_stack_unwind import {helper_name}; " f"print({helper_name}({unwinder_name!r}));" ) run_env = os.environ.copy() @@ -235,15 +244,17 @@ def _unwind_after_executor_free_result(unwinder_name, **env): @support.requires_gil_enabled("test requires the GIL enabled") @unittest.skipIf(support.is_wasi, "test not supported on WASI") -class FramePointerUnwindTests(unittest.TestCase): +class ManualStackUnwindTests(unittest.TestCase): def setUp(self): super().setUp() machine = platform.machine().lower() - expected = _frame_pointers_expected(machine) + expected = _manual_unwind_expected(machine) if expected is None: - self.skipTest(f"unsupported architecture for frame pointer check: {machine}") + self.skipTest( + f"unsupported architecture for manual stack unwind check: {machine}" + ) if expected == "crash": self.skipTest(f"test does crash on {machine}") @@ -251,12 +262,14 @@ def setUp(self): _testinternalcapi.manual_frame_pointer_unwind() except RuntimeError as exc: if "not supported" in str(exc): - self.skipTest("manual frame pointer unwinding not supported on this platform") + self.skipTest( + "manual stack unwinding not supported on this platform" + ) raise self.machine = machine - self.frame_pointers_expected = expected + self.manual_unwind_expected = expected - def test_manual_unwind_respects_frame_pointers(self): + def test_manual_unwind_finds_expected_frames(self): jit_available = hasattr(sys, "_jit") and sys._jit.is_available() envs = [({"PYTHON_JIT": "0"}, False)] if jit_available: @@ -268,7 +281,7 @@ def test_manual_unwind_respects_frame_pointers(self): jit_frames = result["jit_frames"] python_frames = result.get("python_frames", 0) jit_backend = result.get("jit_backend") - if self.frame_pointers_expected: + if self.manual_unwind_expected: self.assertGreaterEqual( python_frames, STACK_DEPTH, From 3a62c8f13ab9ab0373fe2bf675b02708cf4f6a6e Mon Sep 17 00:00:00 2001 From: sobolevn Date: Fri, 8 May 2026 18:34:48 +0300 Subject: [PATCH 5/5] gh-149537: Remove kw parameters from python version of `reduce` (#149538) --- Doc/whatsnew/3.16.rst | 6 ++++ Lib/functools.py | 35 ++++--------------- Lib/test/test_functools.py | 14 ++++---- ...-05-08-08-24-01.gh-issue-149537.hVFVnt.rst | 2 ++ 4 files changed, 22 insertions(+), 35 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-05-08-08-24-01.gh-issue-149537.hVFVnt.rst diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index 98a8644884a8d7..4ddc836d9b29e4 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -114,6 +114,12 @@ annotationlib Use :meth:`annotationlib.ForwardRef.evaluate` or :func:`typing.evaluate_forward_ref` instead. +functools +--------- + +* Calling the Python implementation of :func:`functools.reduce` with *function* + or *sequence* as keyword arguments has been deprecated since Python 3.14. + sysconfig --------- diff --git a/Lib/functools.py b/Lib/functools.py index cd374631f16792..73274b94ef37da 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -234,7 +234,7 @@ def __ge__(self, other): _initial_missing = object() -def reduce(function, sequence, initial=_initial_missing): +def reduce(function, sequence, /, initial=_initial_missing): """ reduce(function, iterable, /[, initial]) -> value @@ -264,6 +264,11 @@ def reduce(function, sequence, initial=_initial_missing): return value +try: + from _functools import reduce +except ImportError: + pass + ################################################################################ ### partial() argument application @@ -1178,31 +1183,3 @@ def __get__(self, instance, owner=None): return val __class_getitem__ = classmethod(GenericAlias) - -def _warn_python_reduce_kwargs(py_reduce): - @wraps(py_reduce) - def wrapper(*args, **kwargs): - if 'function' in kwargs or 'sequence' in kwargs: - import os - import warnings - warnings.warn( - 'Calling functools.reduce with keyword arguments ' - '"function" or "sequence" ' - 'is deprecated in Python 3.14 and will be ' - 'forbidden in Python 3.16.', - DeprecationWarning, - skip_file_prefixes=(os.path.dirname(__file__),)) - return py_reduce(*args, **kwargs) - return wrapper - -reduce = _warn_python_reduce_kwargs(reduce) -del _warn_python_reduce_kwargs - -# The import of the C accelerated version of reduce() has been moved -# here due to gh-121676. In Python 3.16, _warn_python_reduce_kwargs() -# should be removed and the import block should be moved back right -# after the definition of reduce(). -try: - from _functools import reduce -except ImportError: - pass diff --git a/Lib/test/test_functools.py b/Lib/test/test_functools.py index a8ee7d119e4bc6..c30386afe41849 100644 --- a/Lib/test/test_functools.py +++ b/Lib/test/test_functools.py @@ -1134,6 +1134,14 @@ def add(x, y): self.assertRaises(TypeError, self.reduce, add, [0, 1], initial="") self.assertEqual(self.reduce(42, "", initial="1"), "1") # func is never called with one item + def test_reduce_with_kwargs(self): + with self.assertRaises(TypeError): + self.reduce(function=lambda x, y: (x or 1) + y, sequence=[1, 2, 3, 4, 5]) + with self.assertRaises(TypeError): + self.reduce(function=lambda x, y: x + y, sequence=[1, 2, 3, 4, 5], initial=1) + with self.assertRaises(TypeError): + self.reduce(lambda x, y: x + y, sequence=[1, 2, 3, 4, 5], initial=1) + @unittest.skipUnless(c_functools, 'requires the C _functools module') class TestReduceC(TestReduce, unittest.TestCase): @@ -1144,12 +1152,6 @@ class TestReduceC(TestReduce, unittest.TestCase): class TestReducePy(TestReduce, unittest.TestCase): reduce = staticmethod(py_functools.reduce) - def test_reduce_with_kwargs(self): - with self.assertWarns(DeprecationWarning): - self.reduce(function=lambda x, y: x + y, sequence=[1, 2, 3, 4, 5], initial=1) - with self.assertWarns(DeprecationWarning): - self.reduce(lambda x, y: x + y, sequence=[1, 2, 3, 4, 5], initial=1) - class TestCmpToKey: diff --git a/Misc/NEWS.d/next/Library/2026-05-08-08-24-01.gh-issue-149537.hVFVnt.rst b/Misc/NEWS.d/next/Library/2026-05-08-08-24-01.gh-issue-149537.hVFVnt.rst new file mode 100644 index 00000000000000..2fb0359a2ea893 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-05-08-08-24-01.gh-issue-149537.hVFVnt.rst @@ -0,0 +1,2 @@ +Remove kw parameters from python version of :func:`functools.reduce` +function.