# HG changeset patch # User Toby Tobkin@WIN-S82OFKUJHG0.localdomain # Date 1450380199 28800 # Thu Dec 17 11:23:19 2015 -0800 # Node ID 3e4837dc74cfc1fd02c1aff39fea661cc02a5238 # Parent 5176e8a2e2589b707e94020b86cbbb2831af4790 Proposed fix for issue 24505 diff -r 5176e8a2e258 -r 3e4837dc74cf Lib/shutil.py --- a/Lib/shutil.py Wed Dec 09 19:45:07 2015 +0200 +++ b/Lib/shutil.py Thu Dec 17 11:23:19 2015 -0800 @@ -1098,47 +1098,119 @@ return (os.path.exists(fn) and os.access(fn, mode) and not os.path.isdir(fn)) - # If we're given a path with a directory part, look it up directly rather - # than referring to PATH directories. This includes checking relative to the - # current directory, e.g. ./script - if os.path.dirname(cmd): - if _access_check(cmd, mode): - return cmd + # Checks if a given command is an internal Windows NT command such + # as CHDIR Required to properly emulate the Windows command search + # sequence in the event that a requested command also matches an + # internal shell command. See: + # https://technet.microsoft.com/en-us/library/cc723564.aspx + def _is_windows_nt_internal_command(_cmd): + nt_internal_commands = { + 'ASSOC', 'CALL', 'CHDIR', 'CD', 'CLS', 'COLOR', 'COPY', 'DATE', + 'DIR', 'DPATH', 'ECHO', 'ENDLOCAL', 'ERASE', 'DEL', 'EXIT', 'FOR', + 'FTYPE', 'GOTO', 'IF', 'MKDIR', 'MD', 'MOVE', 'PATH', 'PAUSE', + 'POPD', 'PROMPT', 'PUSHD', 'REM', 'RENAME', 'REN', 'RMDIR', 'RD', + 'SET', 'SETLOCAL', 'SHIFT', 'START', 'TIME', 'TITLE', 'TYPE', 'VER' + } + cmd_upper = _cmd.upper() + + if sys.platform != "win32": + return False + else: + return cmd_upper in nt_internal_commands + + # Implementation of which for supported Windows platforms. Windows + # has different semantics for command execution than *nix platforms + def _which_windows_nt(_cmd, _mode, _path): + # Per cmd.exe's specification, NT internal commands not prefaced + # by a path separator are executed as a shell command. E.g.: + # "CHDIR" will execute a shell command, but ".\\CHDIR" will + # search for an executable named "CHDIR" + if not os.path.dirname(_cmd) and _is_windows_nt_internal_command(_cmd): + return None + + # On Windows, a command such as ".\\python" can match + # ".\\python.exe" or even ".\\python.bat". PATHEXT lists these + # extensions. However, ".\\python.exe" will not match + # ".\\python.exe.exe" + pathext = [ext.lower() for ext in + os.environ.get("PATHEXT", "").split(os.pathsep)] + file_name = os.path.split(_cmd)[1].lower() + file_ext = os.path.splitext(file_name)[1].lower() + if file_ext == '': + # order matters to the Windows shell + all_potential_matches = [file_name + ext for ext in pathext] + elif file_ext in pathext: + all_potential_matches = [file_name] + else: + # Windows cmd.exe won't execute a file with a non-pathext + # extension, so return None + return None + + # If the provided command is a specific path to an executable + # (e.g. '.\\python.exe' or 'C:\\bin\\putty.exe', ignore the PATH + # env variable + if os.path.dirname(_cmd): + search_paths = [os.path.split(_cmd)[0]] + if not os.path.isdir(search_paths[0]): + return None + else: + if _path == None: + search_paths = os.environ.get("PATH", "") + else: + search_paths = _path + # If we don't return None, the user will get a nonsense + # error if os.listdir() is called later + if search_paths == '': + return None + search_paths = [os.curdir] + search_paths.split(os.pathsep) + + # order of nesting of the loops matters + for cur_path in search_paths: + for cur_potential_match in all_potential_matches: + for cur_listing in os.listdir(cur_path): + file_path = os.path.join(cur_path, cur_listing) + if (cur_potential_match == cur_listing.lower() and + os.path.isfile(file_path) and + _access_check(file_path, _mode)): + return file_path + return None - if path is None: - path = os.environ.get("PATH", os.defpath) - if not path: + # Implementation of which for all supported platforms besides + # Windows + def _which(_cmd, _mode, _path): + # If we're given a path with a directory part, look it up + # directly rather than referring to PATH directories. This + # includes checking relative to the current directory, e.g. + # ./script + if os.path.dirname(_cmd): + if _access_check(_cmd, _mode): + return _cmd + return None + + if _path is None: + _path = os.environ.get("PATH", os.defpath) + if not _path: + return None + _path = _path.split(os.pathsep) + + files = [_cmd] + + seen = set() + for dir in _path: + normdir = os.path.normcase(dir) + if not normdir in seen: + seen.add(normdir) + for thefile in files: + name = os.path.join(dir, thefile) + if _access_check(name, _mode): + return name return None - path = path.split(os.pathsep) - if sys.platform == "win32": - # The current directory takes precedence on Windows. - if not os.curdir in path: - path.insert(0, os.curdir) - - # PATHEXT is necessary to check on Windows. - pathext = os.environ.get("PATHEXT", "").split(os.pathsep) - # See if the given file matches any of the expected path extensions. - # This will allow us to short circuit when given "python.exe". - # If it does match, only test that one, otherwise we have to try - # others. - if any(cmd.lower().endswith(ext.lower()) for ext in pathext): - files = [cmd] - else: - files = [cmd + ext for ext in pathext] + # The logic for which() on Windows (called where.exe) is separated + # from the logic for which() on other platforms because of the + # substantially different semantics + if os.name == 'nt': + return _which_windows_nt(cmd, mode, path) else: - # On other platforms you don't have things like PATHEXT to tell you - # what file suffixes are executable, so just pass on cmd as-is. - files = [cmd] - - seen = set() - for dir in path: - normdir = os.path.normcase(dir) - if not normdir in seen: - seen.add(normdir) - for thefile in files: - name = os.path.join(dir, thefile) - if _access_check(name, mode): - return name - return None + return _which(cmd, mode, path) diff -r 5176e8a2e258 -r 3e4837dc74cf Lib/test/test_shutil.py --- a/Lib/test/test_shutil.py Wed Dec 09 19:45:07 2015 +0200 +++ b/Lib/test/test_shutil.py Thu Dec 17 11:23:19 2015 -0800 @@ -1434,6 +1434,76 @@ rv = shutil.which(relpath, path=base_dir) self.assertIsNone(rv) + @unittest.skipUnless(os.name == "nt", "test is Windows-specific") + def test_windows_relative_cmd_adds_pathext(self): + # Issue #24505 "shutil.which result wrong on Windows" Works the + # same as test_relative_cmd, except e.g. ./xyz should match + # ./xyz.exe + base_dir, tail_dir = os.path.split(self.dir) + relpath = os.path.join(tail_dir, self.file) + relpath_no_extension = os.path.splitext(relpath)[0] + with support.change_cwd(path=base_dir): + rv = shutil.which(relpath_no_extension, path=self.temp_dir) + self.assertEqual(rv, relpath) + # But it shouldn't be searched in PATH directories (issue #16957). + with support.change_cwd(path=self.dir): + rv = shutil.which(relpath_no_extension, path=base_dir) + self.assertIsNone(rv) + + @unittest.skipUnless(os.name == "nt", "test is Windows-specific") + def test_windows_command_with_match_to_internal_nt_command(self): + # Issue #24505 "shutil.which result wrong on Windows" Corner + # case handling of when a command is looked for that also + # matches an internal Windows NT command. See: + # https://technet.microsoft.com/en-us/library/cc723564.aspx + example_nt_cmd = "chdir" + nt_cmd_with_ext = "chdir.exe" + rel_path_nt_cmd_ext = ".\\" + nt_cmd_with_ext + + # Can't use tempfile.NamedTemporaryFile because we need to name + # the file something in particular + try: + with support.change_cwd(path=self.temp_dir): + # Case 1 of 2: file named "chdir" + f = open(example_nt_cmd, 'w') + f.close() + + rv = shutil.which(cmd=example_nt_cmd, path=self.temp_dir) + self.assertIsNone(rv, "NT internal commands not part of a path" + "should never return an executable") + rv = shutil.which( + cmd=".\\" + example_nt_cmd, + path=self.temp_dir) + self.assertIsNone(rv, "which should not return a path to a" + "non-executable file") + + os.remove(example_nt_cmd) + + # Case 2 of 2: file named "chdir.exe" + f = open(nt_cmd_with_ext, 'w') + f.close() + + rv = shutil.which(cmd=example_nt_cmd, path=self.temp_dir) + self.assertIsNone(rv, "NT internal commands not part of a path" + "should never return an executable") + rv = shutil.which(cmd='.\\' + example_nt_cmd, + path=self.temp_dir) + self.assertEqual(rv, rel_path_nt_cmd_ext) + rv = shutil.which(cmd=nt_cmd_with_ext, path=self.temp_dir) + self.assertEqual(rv, rel_path_nt_cmd_ext) + rv = shutil.which(cmd='.\\' + nt_cmd_with_ext, + path=self.temp_dir) + self.assertEqual(rv, rel_path_nt_cmd_ext) + + os.remove(nt_cmd_with_ext) + finally: + path_nt_cmd = os.path.join(self.temp_dir, example_nt_cmd) + path_nt_cmd_ext = os.path.join(self.temp_dir, nt_cmd_with_ext) + if os.path.isfile(path_nt_cmd): + os.remove(path_nt_cmd) + if os.path.isfile(path_nt_cmd_ext): + os.remove(path_nt_cmd_ext) + def test_cwd(self): # Issue #16957 base_dir = os.path.dirname(self.dir) @@ -1473,7 +1543,7 @@ # Ask for the file without the ".exe" extension, then ensure that # it gets found properly with the extension. rv = shutil.which(self.file[:-4], path=self.dir) - self.assertEqual(rv, self.temp_file.name[:-4] + ".EXE") + self.assertEqual(rv.lower(), (self.temp_file.name[:-4] + ".EXE").lower()) def test_environ_path(self): with support.EnvironmentVarGuard() as env: