From a23ffa36f68f608ea034ed59eb511c3bf52a582d Mon Sep 17 00:00:00 2001 From: a-eT <32207200078@e.gzhu.edu.cn> Date: Tue, 2 Dec 2025 01:11:26 +0800 Subject: [PATCH 1/4] gh-142155: Fix infinite recursion in shutil.copytree on Windows junctions --- Lib/shutil.py | 20 +++++++++++++++----- Lib/test/test_shutil.py | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/Lib/shutil.py b/Lib/shutil.py index 8d8fe145567822..16a5fdbc633ec6 100644 --- a/Lib/shutil.py +++ b/Lib/shutil.py @@ -543,12 +543,22 @@ def _ignore_patterns(path, names): return _ignore_patterns def _copytree(entries, src, dst, symlinks, ignore, copy_function, - ignore_dangling_symlinks, dirs_exist_ok=False): + ignore_dangling_symlinks, dirs_exist_ok=False, _seen=None): if ignore is not None: ignored_names = ignore(os.fspath(src), [x.name for x in entries]) else: ignored_names = () + # Track visited directories to detect cycles (e.g., Windows junctions) + if _seen is None: + _seen = set() + src_st = os.stat(src) + src_id = (src_st.st_dev, src_st.st_ino) + if src_id in _seen: + raise Error([(src, dst, "Infinite recursion detected")]) + _seen = _seen.copy() + _seen.add(src_id) + os.makedirs(dst, exist_ok=dirs_exist_ok) errors = [] use_srcentry = copy_function is copy2 or copy_function is copy @@ -583,12 +593,12 @@ def _copytree(entries, src, dst, symlinks, ignore, copy_function, if srcentry.is_dir(): copytree(srcobj, dstname, symlinks, ignore, copy_function, ignore_dangling_symlinks, - dirs_exist_ok) + dirs_exist_ok, _seen=_seen) else: copy_function(srcobj, dstname) elif srcentry.is_dir(): copytree(srcobj, dstname, symlinks, ignore, copy_function, - ignore_dangling_symlinks, dirs_exist_ok) + ignore_dangling_symlinks, dirs_exist_ok, _seen=_seen) else: # Will raise a SpecialFileError for unsupported file types copy_function(srcobj, dstname) @@ -609,7 +619,7 @@ def _copytree(entries, src, dst, symlinks, ignore, copy_function, return dst def copytree(src, dst, symlinks=False, ignore=None, copy_function=copy2, - ignore_dangling_symlinks=False, dirs_exist_ok=False): + ignore_dangling_symlinks=False, dirs_exist_ok=False, _seen=None): """Recursively copy a directory tree and return the destination directory. If exception(s) occur, an Error is raised with a list of reasons. @@ -654,7 +664,7 @@ def copytree(src, dst, symlinks=False, ignore=None, copy_function=copy2, return _copytree(entries=entries, src=src, dst=dst, symlinks=symlinks, ignore=ignore, copy_function=copy_function, ignore_dangling_symlinks=ignore_dangling_symlinks, - dirs_exist_ok=dirs_exist_ok) + dirs_exist_ok=dirs_exist_ok, _seen=_seen) if hasattr(os.stat_result, 'st_file_attributes'): def _rmtree_islink(st): diff --git a/Lib/test/test_shutil.py b/Lib/test/test_shutil.py index ebb6cf88336249..6fe814ff8513ca 100644 --- a/Lib/test/test_shutil.py +++ b/Lib/test/test_shutil.py @@ -1099,6 +1099,42 @@ def test_copytree_subdirectory(self): rv = shutil.copytree(src_dir, dst_dir) self.assertEqual(['pol'], os.listdir(rv)) + @unittest.skipUnless(sys.platform == "win32", "Windows-specific test") + def test_copytree_recursive_junction(self): + # Test that copytree raises Error for recursive junctions (Windows) + import subprocess + base_dir = self.mkdtemp() + self.addCleanup(shutil.rmtree, base_dir, ignore_errors=True) + + # Create source directory structure + src_dir = os.path.join(base_dir, "Source") + junction_dir = os.path.join(src_dir, "Junction") + os.makedirs(junction_dir) + + # Create a junction pointing to its parent, creating a cycle + junction_target = os.path.dirname(junction_dir) # Points to Source + try: + result = subprocess.run( + ["mklink", "/J", junction_dir, junction_target], + shell=True, check=False, capture_output=True, text=True + ) + if result.returncode != 0: + # Skip if we don't have permission to create junctions + self.skipTest(f"Failed to create junction: {result.stderr.strip()}") + except Exception as e: + # Skip if mklink is not available or fails for any reason + self.skipTest(f"Failed to create junction: {e}") + + # Create destination directory + dst_dir = os.path.join(base_dir, "Dest") + + # Test that copytree raises Error with infinite recursion message + with self.assertRaises(shutil.Error) as cm: + shutil.copytree(src_dir, dst_dir) + + # Verify the error message contains "Infinite recursion detected" + self.assertIn("Infinite recursion detected", str(cm.exception)) + class TestCopy(BaseTest, unittest.TestCase): ### shutil.copymode From 0013914f10e64cc801ea16dc427902a92a775fe2 Mon Sep 17 00:00:00 2001 From: "blurb-it[bot]" <43283697+blurb-it[bot]@users.noreply.github.com> Date: Mon, 1 Dec 2025 17:17:13 +0000 Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=93=9C=F0=9F=A4=96=20Added=20by=20blu?= =?UTF-8?q?rb=5Fit.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../next/Library/2025-12-01-17-17-12.gh-issue-142155.wiUAfL.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 Misc/NEWS.d/next/Library/2025-12-01-17-17-12.gh-issue-142155.wiUAfL.rst diff --git a/Misc/NEWS.d/next/Library/2025-12-01-17-17-12.gh-issue-142155.wiUAfL.rst b/Misc/NEWS.d/next/Library/2025-12-01-17-17-12.gh-issue-142155.wiUAfL.rst new file mode 100644 index 00000000000000..ae9abf241ee7d5 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-12-01-17-17-12.gh-issue-142155.wiUAfL.rst @@ -0,0 +1 @@ +Fix infinite recursion crash in :func:`shutil.copytree` when a cycle involving a directory junction is present on Windows. From cee3c73f2d17cb002b141021b90835d260d56ea6 Mon Sep 17 00:00:00 2001 From: a-eT <32207200078@e.gzhu.edu.cn> Date: Thu, 4 Dec 2025 02:31:21 +0800 Subject: [PATCH 3/4] Address review: optimize recursion with frozenset, cleanup imports and naming --- Lib/shutil.py | 6 +++--- Lib/test/test_shutil.py | 5 ++--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/Lib/shutil.py b/Lib/shutil.py index 16a5fdbc633ec6..db7a5f82b87477 100644 --- a/Lib/shutil.py +++ b/Lib/shutil.py @@ -551,13 +551,13 @@ def _copytree(entries, src, dst, symlinks, ignore, copy_function, # Track visited directories to detect cycles (e.g., Windows junctions) if _seen is None: - _seen = set() + _seen = frozenset() src_st = os.stat(src) src_id = (src_st.st_dev, src_st.st_ino) if src_id in _seen: raise Error([(src, dst, "Infinite recursion detected")]) - _seen = _seen.copy() - _seen.add(src_id) + + _seen = _seen | {src_id} os.makedirs(dst, exist_ok=dirs_exist_ok) errors = [] diff --git a/Lib/test/test_shutil.py b/Lib/test/test_shutil.py index 6fe814ff8513ca..265fbcc50396a7 100644 --- a/Lib/test/test_shutil.py +++ b/Lib/test/test_shutil.py @@ -1102,13 +1102,12 @@ def test_copytree_subdirectory(self): @unittest.skipUnless(sys.platform == "win32", "Windows-specific test") def test_copytree_recursive_junction(self): # Test that copytree raises Error for recursive junctions (Windows) - import subprocess base_dir = self.mkdtemp() self.addCleanup(shutil.rmtree, base_dir, ignore_errors=True) # Create source directory structure - src_dir = os.path.join(base_dir, "Source") - junction_dir = os.path.join(src_dir, "Junction") + src_dir = os.path.join(base_dir, "source") + junction_dir = os.path.join(src_dir, "junction") os.makedirs(junction_dir) # Create a junction pointing to its parent, creating a cycle From 19f55fb0d459400b6a44f2d4eed50284216d32f8 Mon Sep 17 00:00:00 2001 From: Fatin <32006697+ChuheLin@users.noreply.github.com> Date: Thu, 4 Dec 2025 02:33:42 +0800 Subject: [PATCH 4/4] Update Lib/test/test_shutil.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com> --- Lib/test/test_shutil.py | 1 + 1 file changed, 1 insertion(+) diff --git a/Lib/test/test_shutil.py b/Lib/test/test_shutil.py index 265fbcc50396a7..2af394b79769f2 100644 --- a/Lib/test/test_shutil.py +++ b/Lib/test/test_shutil.py @@ -1134,6 +1134,7 @@ def test_copytree_recursive_junction(self): # Verify the error message contains "Infinite recursion detected" self.assertIn("Infinite recursion detected", str(cm.exception)) + class TestCopy(BaseTest, unittest.TestCase): ### shutil.copymode