diff options
Diffstat (limited to 'Lib')
-rw-r--r-- | Lib/shutil.py | 48 | ||||
-rw-r--r-- | Lib/test/test_os.py | 61 | ||||
-rw-r--r-- | Lib/test/test_shutil.py | 69 | ||||
-rw-r--r-- | Lib/test/test_tools/test_lll.py | 6 | ||||
-rw-r--r-- | Lib/test/test_venv.py | 6 |
5 files changed, 159 insertions, 31 deletions
diff --git a/Lib/shutil.py b/Lib/shutil.py index ab1a7d6..39f793b 100644 --- a/Lib/shutil.py +++ b/Lib/shutil.py @@ -452,7 +452,14 @@ def _copytree(entries, src, dst, symlinks, ignore, copy_function, dstname = os.path.join(dst, srcentry.name) srcobj = srcentry if use_srcentry else srcname try: - if srcentry.is_symlink(): + is_symlink = srcentry.is_symlink() + if is_symlink and os.name == 'nt': + # Special check for directory junctions, which appear as + # symlinks but we want to recurse. + lstat = srcentry.stat(follow_symlinks=False) + if lstat.st_reparse_tag == stat.IO_REPARSE_TAG_MOUNT_POINT: + is_symlink = False + if is_symlink: linkto = os.readlink(srcname) if symlinks: # We can't just leave it to `copy_function` because legacy @@ -537,6 +544,37 @@ def copytree(src, dst, symlinks=False, ignore=None, copy_function=copy2, ignore_dangling_symlinks=ignore_dangling_symlinks, dirs_exist_ok=dirs_exist_ok) +if hasattr(stat, 'FILE_ATTRIBUTE_REPARSE_POINT'): + # Special handling for directory junctions to make them behave like + # symlinks for shutil.rmtree, since in general they do not appear as + # regular links. + def _rmtree_isdir(entry): + try: + st = entry.stat(follow_symlinks=False) + return (stat.S_ISDIR(st.st_mode) and not + (st.st_file_attributes & stat.FILE_ATTRIBUTE_REPARSE_POINT + and st.st_reparse_tag == stat.IO_REPARSE_TAG_MOUNT_POINT)) + except OSError: + return False + + def _rmtree_islink(path): + try: + st = os.lstat(path) + return (stat.S_ISLNK(st.st_mode) or + (st.st_file_attributes & stat.FILE_ATTRIBUTE_REPARSE_POINT + and st.st_reparse_tag == stat.IO_REPARSE_TAG_MOUNT_POINT)) + except OSError: + return False +else: + def _rmtree_isdir(entry): + try: + return entry.is_dir(follow_symlinks=False) + except OSError: + return False + + def _rmtree_islink(path): + return os.path.islink(path) + # version vulnerable to race conditions def _rmtree_unsafe(path, onerror): try: @@ -547,11 +585,7 @@ def _rmtree_unsafe(path, onerror): entries = [] for entry in entries: fullname = entry.path - try: - is_dir = entry.is_dir(follow_symlinks=False) - except OSError: - is_dir = False - if is_dir: + if _rmtree_isdir(entry): try: if entry.is_symlink(): # This can only happen if someone replaces @@ -681,7 +715,7 @@ def rmtree(path, ignore_errors=False, onerror=None): os.close(fd) else: try: - if os.path.islink(path): + if _rmtree_islink(path): # symlinks to directories are forbidden, see bug #1669 raise OSError("Cannot call rmtree on a symbolic link") except OSError: diff --git a/Lib/test/test_os.py b/Lib/test/test_os.py index 15fd65b..ba9f5c3 100644 --- a/Lib/test/test_os.py +++ b/Lib/test/test_os.py @@ -8,6 +8,7 @@ import codecs import contextlib import decimal import errno +import fnmatch import fractions import itertools import locale @@ -2253,6 +2254,20 @@ class ReadlinkTests(unittest.TestCase): filelinkb = os.fsencode(filelink) filelinkb_target = os.fsencode(filelink_target) + def assertPathEqual(self, left, right): + left = os.path.normcase(left) + right = os.path.normcase(right) + if sys.platform == 'win32': + # Bad practice to blindly strip the prefix as it may be required to + # correctly refer to the file, but we're only comparing paths here. + has_prefix = lambda p: p.startswith( + b'\\\\?\\' if isinstance(p, bytes) else '\\\\?\\') + if has_prefix(left): + left = left[4:] + if has_prefix(right): + right = right[4:] + self.assertEqual(left, right) + def setUp(self): self.assertTrue(os.path.exists(self.filelink_target)) self.assertTrue(os.path.exists(self.filelinkb_target)) @@ -2274,14 +2289,14 @@ class ReadlinkTests(unittest.TestCase): os.symlink(self.filelink_target, self.filelink) self.addCleanup(support.unlink, self.filelink) filelink = FakePath(self.filelink) - self.assertEqual(os.readlink(filelink), self.filelink_target) + self.assertPathEqual(os.readlink(filelink), self.filelink_target) @support.skip_unless_symlink def test_pathlike_bytes(self): os.symlink(self.filelinkb_target, self.filelinkb) self.addCleanup(support.unlink, self.filelinkb) path = os.readlink(FakePath(self.filelinkb)) - self.assertEqual(path, self.filelinkb_target) + self.assertPathEqual(path, self.filelinkb_target) self.assertIsInstance(path, bytes) @support.skip_unless_symlink @@ -2289,7 +2304,7 @@ class ReadlinkTests(unittest.TestCase): os.symlink(self.filelinkb_target, self.filelinkb) self.addCleanup(support.unlink, self.filelinkb) path = os.readlink(self.filelinkb) - self.assertEqual(path, self.filelinkb_target) + self.assertPathEqual(path, self.filelinkb_target) self.assertIsInstance(path, bytes) @@ -2348,16 +2363,12 @@ class Win32SymlinkTests(unittest.TestCase): # was created with target_is_dir==True. os.remove(self.missing_link) - @unittest.skip("currently fails; consider for improvement") def test_isdir_on_directory_link_to_missing_target(self): self._create_missing_dir_link() - # consider having isdir return true for directory links - self.assertTrue(os.path.isdir(self.missing_link)) + self.assertFalse(os.path.isdir(self.missing_link)) - @unittest.skip("currently fails; consider for improvement") def test_rmdir_on_directory_link_to_missing_target(self): self._create_missing_dir_link() - # consider allowing rmdir to remove directory links os.rmdir(self.missing_link) def check_stat(self, link, target): @@ -2453,6 +2464,24 @@ class Win32SymlinkTests(unittest.TestCase): except OSError: pass + def test_appexeclink(self): + root = os.path.expandvars(r'%LOCALAPPDATA%\Microsoft\WindowsApps') + aliases = [os.path.join(root, a) + for a in fnmatch.filter(os.listdir(root), '*.exe')] + + for alias in aliases: + if support.verbose: + print() + print("Testing with", alias) + st = os.lstat(alias) + self.assertEqual(st, os.stat(alias)) + self.assertFalse(stat.S_ISLNK(st.st_mode)) + self.assertEqual(st.st_reparse_tag, stat.IO_REPARSE_TAG_APPEXECLINK) + # testing the first one we see is sufficient + break + else: + self.skipTest("test requires an app execution alias") + @unittest.skipUnless(sys.platform == "win32", "Win32 specific tests") class Win32JunctionTests(unittest.TestCase): junction = 'junctiontest' @@ -2460,25 +2489,29 @@ class Win32JunctionTests(unittest.TestCase): def setUp(self): assert os.path.exists(self.junction_target) - assert not os.path.exists(self.junction) + assert not os.path.lexists(self.junction) def tearDown(self): - if os.path.exists(self.junction): - # os.rmdir delegates to Windows' RemoveDirectoryW, - # which removes junction points safely. - os.rmdir(self.junction) + if os.path.lexists(self.junction): + os.unlink(self.junction) def test_create_junction(self): _winapi.CreateJunction(self.junction_target, self.junction) + self.assertTrue(os.path.lexists(self.junction)) self.assertTrue(os.path.exists(self.junction)) self.assertTrue(os.path.isdir(self.junction)) + self.assertNotEqual(os.stat(self.junction), os.lstat(self.junction)) + self.assertEqual(os.stat(self.junction), os.stat(self.junction_target)) - # Junctions are not recognized as links. + # bpo-37834: Junctions are not recognized as links. self.assertFalse(os.path.islink(self.junction)) + self.assertEqual(os.path.normcase("\\\\?\\" + self.junction_target), + os.path.normcase(os.readlink(self.junction))) def test_unlink_removes_junction(self): _winapi.CreateJunction(self.junction_target, self.junction) self.assertTrue(os.path.exists(self.junction)) + self.assertTrue(os.path.lexists(self.junction)) os.unlink(self.junction) self.assertFalse(os.path.exists(self.junction)) diff --git a/Lib/test/test_shutil.py b/Lib/test/test_shutil.py index 88dc4d9..636e3bd 100644 --- a/Lib/test/test_shutil.py +++ b/Lib/test/test_shutil.py @@ -42,6 +42,11 @@ try: except ImportError: UID_GID_SUPPORT = False +try: + import _winapi +except ImportError: + _winapi = None + def _fake_rename(*args, **kwargs): # Pretend the destination path is on a different filesystem. raise OSError(getattr(errno, 'EXDEV', 18), "Invalid cross-device link") @@ -226,6 +231,47 @@ class TestShutil(unittest.TestCase): self.assertTrue(os.path.exists(dir3)) self.assertTrue(os.path.exists(file1)) + @unittest.skipUnless(_winapi, 'only relevant on Windows') + def test_rmtree_fails_on_junctions(self): + tmp = self.mkdtemp() + dir_ = os.path.join(tmp, 'dir') + os.mkdir(dir_) + link = os.path.join(tmp, 'link') + _winapi.CreateJunction(dir_, link) + self.assertRaises(OSError, shutil.rmtree, link) + self.assertTrue(os.path.exists(dir_)) + self.assertTrue(os.path.lexists(link)) + errors = [] + def onerror(*args): + errors.append(args) + shutil.rmtree(link, onerror=onerror) + self.assertEqual(len(errors), 1) + self.assertIs(errors[0][0], os.path.islink) + self.assertEqual(errors[0][1], link) + self.assertIsInstance(errors[0][2][1], OSError) + + @unittest.skipUnless(_winapi, 'only relevant on Windows') + def test_rmtree_works_on_junctions(self): + tmp = self.mkdtemp() + dir1 = os.path.join(tmp, 'dir1') + dir2 = os.path.join(dir1, 'dir2') + dir3 = os.path.join(tmp, 'dir3') + for d in dir1, dir2, dir3: + os.mkdir(d) + file1 = os.path.join(tmp, 'file1') + write_file(file1, 'foo') + link1 = os.path.join(dir1, 'link1') + _winapi.CreateJunction(dir2, link1) + link2 = os.path.join(dir1, 'link2') + _winapi.CreateJunction(dir3, link2) + link3 = os.path.join(dir1, 'link3') + _winapi.CreateJunction(file1, link3) + # make sure junctions are removed but not followed + shutil.rmtree(dir1) + self.assertFalse(os.path.exists(dir1)) + self.assertTrue(os.path.exists(dir3)) + self.assertTrue(os.path.exists(file1)) + def test_rmtree_errors(self): # filename is guaranteed not to exist filename = tempfile.mktemp() @@ -754,8 +800,12 @@ class TestShutil(unittest.TestCase): src_stat = os.lstat(src_link) shutil.copytree(src_dir, dst_dir, symlinks=True) self.assertTrue(os.path.islink(os.path.join(dst_dir, 'sub', 'link'))) - self.assertEqual(os.readlink(os.path.join(dst_dir, 'sub', 'link')), - os.path.join(src_dir, 'file.txt')) + actual = os.readlink(os.path.join(dst_dir, 'sub', 'link')) + # Bad practice to blindly strip the prefix as it may be required to + # correctly refer to the file, but we're only comparing paths here. + if os.name == 'nt' and actual.startswith('\\\\?\\'): + actual = actual[4:] + self.assertEqual(actual, os.path.join(src_dir, 'file.txt')) dst_stat = os.lstat(dst_link) if hasattr(os, 'lchmod'): self.assertEqual(dst_stat.st_mode, src_stat.st_mode) @@ -886,7 +936,6 @@ class TestShutil(unittest.TestCase): shutil.copytree(src, dst, copy_function=custom_cpfun) self.assertEqual(len(flag), 1) - @unittest.skipIf(os.name == 'nt', 'temporarily disabled on Windows') @unittest.skipUnless(hasattr(os, 'link'), 'requires os.link') def test_dont_copy_file_onto_link_to_itself(self): # bug 851123. @@ -941,6 +990,20 @@ class TestShutil(unittest.TestCase): finally: shutil.rmtree(TESTFN, ignore_errors=True) + @unittest.skipUnless(_winapi, 'only relevant on Windows') + def test_rmtree_on_junction(self): + os.mkdir(TESTFN) + try: + src = os.path.join(TESTFN, 'cheese') + dst = os.path.join(TESTFN, 'shop') + os.mkdir(src) + open(os.path.join(src, 'spam'), 'wb').close() + _winapi.CreateJunction(src, dst) + self.assertRaises(OSError, shutil.rmtree, dst) + shutil.rmtree(dst, ignore_errors=True) + finally: + shutil.rmtree(TESTFN, ignore_errors=True) + # Issue #3002: copyfile and copytree block indefinitely on named pipes @unittest.skipUnless(hasattr(os, "mkfifo"), 'requires os.mkfifo()') def test_copyfile_named_pipe(self): diff --git a/Lib/test/test_tools/test_lll.py b/Lib/test/test_tools/test_lll.py index f3fbe961..b01e218 100644 --- a/Lib/test/test_tools/test_lll.py +++ b/Lib/test/test_tools/test_lll.py @@ -1,6 +1,7 @@ """Tests for the lll script in the Tools/script directory.""" import os +import sys import tempfile from test import support from test.test_tools import skip_if_missing, import_tool @@ -26,12 +27,13 @@ class lllTests(unittest.TestCase): with support.captured_stdout() as output: self.lll.main([dir1, dir2]) + prefix = '\\\\?\\' if os.name == 'nt' else '' self.assertEqual(output.getvalue(), f'{dir1}:\n' - f'symlink -> {fn1}\n' + f'symlink -> {prefix}{fn1}\n' f'\n' f'{dir2}:\n' - f'symlink -> {fn2}\n' + f'symlink -> {prefix}{fn2}\n' ) diff --git a/Lib/test/test_venv.py b/Lib/test/test_venv.py index 9724d9e..de93d95 100644 --- a/Lib/test/test_venv.py +++ b/Lib/test/test_venv.py @@ -394,11 +394,7 @@ class EnsurePipTest(BaseTest): with open(os.devnull, "rb") as f: self.assertEqual(f.read(), b"") - # Issue #20541: os.path.exists('nul') is False on Windows - if os.devnull.lower() == 'nul': - self.assertFalse(os.path.exists(os.devnull)) - else: - self.assertTrue(os.path.exists(os.devnull)) + self.assertTrue(os.path.exists(os.devnull)) def do_test_with_pip(self, system_site_packages): rmtree(self.env_dir) |