Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Windows: Provide an option to disable search in CWD on shutil.which #91558

Open
naveen521kk opened this issue Apr 15, 2022 · 6 comments
Open

Comments

@naveen521kk
Copy link

I would like to search for an executable in the PATH but not from the current working directory (cwd). By default, as documented in shutil.which, it prepends the cwd before PATH, so it's not possible to get the expected executable.

For example, I have a whoami.exe in the cwd but I would like to get the one from the system32 directory; there's no way to do so using shutil.which.

>>> import shutil
>>> shutil.which('whoami')
'.\\whoami.EXE'

I think it makes sense for that to be the default behaviour since that's how it is on Windows. I would like to have a parameter to disable this behaviour in shutil.which.

Thanks!

@eryksun
Copy link
Contributor

eryksun commented Apr 15, 2022

WinAPI CreateProcessW() and the CMD shell respect NeedCurrentDirectoryForExePathW(). This function is false only if the ExeName argument contains no backslashes and the environment variable NoDefaultCurrentDirectoryInExePath is defined. The API should be used instead of the environment variable, in case the function is modified to depend on a different variable, registry setting, or a compatibility mode.

Note that NeedCurrentDirectoryForExePathW() does not handle forward slash as a path separator, as is actually the case with many functions that work with file paths in Windows. The ExeName argument has to be normalized to replace forward slashes with backslashes if implementing a Windows-style search matters. In the Windows API, if a filename has no root or drive, the system's search routine tries to resolve it against each directory in the search path, until the first match, even if the filename contains one or more backslashes. In this case, the current directory is the second directory that's checked, after the application directory. In POSIX, as far as I know, a relative path that contains one or more slashes is always resolved against the current directory.

@naveen521kk
Copy link
Author

Are you suggesting that NeedCurrentDirectoryForExePathW should be used rather than providing a parameter to override this behaviour?

@eryksun
Copy link
Contributor

eryksun commented Apr 15, 2022

Yes, a long time ago I suggested that shutil.which() should check NeedCurrentDirectoryForExePathW() (added in Windows Vista, circa 2006) to determine whether or not the current working directory should be included. This would match the behavior of both CreateProcessW() and the CMD shell, as used by subprocess.Popen with both shell=False (default) and shell=True. However, nothing came of the suggestion.

Since shutil.which() implements a POSIX style search, the check only applies when the cmd argument contains no slashes or backslashes, as determined by os.path.dirname().

@naveen521kk
Copy link
Author

Thanks for the reply! Using that API makes sense as it matches with how cmd/CreateProcess works, though I think it would be better to have a flag in shutil.which which disables this behaviour. What do you think?

@lazka
Copy link
Contributor

lazka commented Apr 16, 2022

One difference between current shutil.which() and CreateProcessW(), is that the later always prefers binaries in the system directory, so things like tar, curl, bash would then never return the version in PATH, but the Windows shipped version. I depend on this in various scripts, though not sure how common that is..

@eryksun
Copy link
Contributor

eryksun commented Apr 17, 2022

CreateProcessW() ... always prefers binaries in the system directory

CreateProcessW() first checks the application directory, even if the path is a relative path that has one or more backslashes (e.g. r'bin\bash.exe'). Next, if NeedCurrentDirectoryForExePathW() is true, it checks the current directory. Next it checks the "System32" directory and the "Windows" directory. If the command still isn't found, it checks the PATH directories, which can explicitly include the current directory as ".". When checking a directory, it looks for the given name and also the name with ".EXE" appended. It doesn't use PATHEXT.

shutil.which() is closer to the CMD shell's executable file search, since it uses PATHEXT and doesn't prefer the application directory and system directories over PATH. Starting with Windows Vista, CMD also calls NeedCurrentDirectoryForExePathW() to determine whether to implicitly include the current directory in the search path.

That said, shutil.which() uses PATHEXT differently from the shell, and thus differently from os.system() and subprocess.Popen with shell=True. CMD uses PATHEXT to expand the search with a set of extensions to try appending in addition to checking for the given name. It doesn't use it as a security policy that limits the search to just an allowed set of file types.

EliahKagan added a commit to EliahKagan/GitPython that referenced this issue Nov 25, 2023
881456b (gitpython-developers#1679) expanded the use of shutil.which in test_index.py
to attempt to mark test_commit_msg_hook_success xfail when bash.exe
is a WSL bash wrapper in System32 (because that test currently
is not passing when the hook is run via bash in a WSL system, which
the WSL bash.exe wraps). But this was not reliable, due to
significant differences between shell and non-shell search behavior
for executable commands on Windows. As the new docstring notes,
lookup due to Popen generally checks System32 before going through
directories in PATH, as this is how CreateProcessW behaves.

- https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-createprocessw
- python/cpython#91558 (comment)

The technique I had used in 881456b also had the shortcoming of
assuming that a bash.exe in System32 was the WSL wrapper. But such
a file may exist on some systems without WSL, and be a bash
interpreter unrelated to WSL that may be able to run hooks.

In addition, one common situation, which was the case on CI before
a step to install a WSL distribution was added, is that WSL is
present but no WSL distributions are installed. In that situation
bash.exe is found in System32, but it can't be used to run any
hooks, because there's no actual system with a bash in it to wrap.
This was not covered before. Unlike some conditions that prevent
a WSL system from being used, such as resource exhaustion blocking
it from being started, the absence of a WSL system should probably
not fail the pytest run, for the same reason as we are trying not
to have the complete *absence* of bash.exe fail the pytest run.
Both conditions should be xfail. Fortunately, the error message
when no distribution exists is distinctive and can be checked for.

There is probably no correct and reasonable way to check LBYL-style
which executable file bash.exe resolves to by using shutil.which,
due to shutil.which and subprocess.Popen's differing seach orders
and other subtleties. So this adds code to do it EAFP-style using
subprocess.run (which itself uses Popen, so giving the same
CreateProcessW behavior). It tries to run a command with bash.exe
whose output pretty reliably shows if the system is WSL or not.

We may fail to get to the point of running that command at all, if
bash.exe is not usable, in which case the failure's details tell us
if bash.exe is absent (xfail), present as the WSL wrapper with no WSL
systems (xfail), or has some other error (not xfail, so the user can
become aware of and proably fix the problem). If we do get to that
point, then a file that is almost always present on both WSL 1 and
WSL 2 systems and almost always absent on any other system is
checked for, to distinguish whether the working bash shell operates
in a WSL system, or outside any such system as e.g. Git Bash does.

See https://superuser.com/a/1749811 on various techniques for
checking for WSL, including the /proc/sys/fs/binfmt_misc/WSLInterop
technique used here (which seems overall may be the most reliable).

Although the Windows CI runners have Git Bash, and this is even the
bash.exe that appears first in PATH (giving rise to the problem
with shutil.which detailed above), it would be a bit awkward to
test the behavior when Git Bash is actually used to run hooks on
CI, because of how Popen selects the System32 bash.exe first,
whether or not any WSL distribution is present. However, local
testing shows that when Git Bash's bash.exe is selected, all four
hook tests in the module pass, both before and after this change,
and furthermore that bash.exe is correctly detected as "native",
continuing to avoid an erronous xfail mark in that case.
EliahKagan added a commit to EliahKagan/GitPython that referenced this issue Nov 25, 2023
881456b (gitpython-developers#1679) expanded the use of shutil.which in test_index.py
to attempt to mark test_commit_msg_hook_success xfail when bash.exe
is a WSL bash wrapper in System32 (because that test currently
is not passing when the hook is run via bash in a WSL system, which
the WSL bash.exe wraps). But this was not reliable, due to
significant differences between shell and non-shell search behavior
for executable commands on Windows. As the new docstring notes,
lookup due to Popen generally checks System32 before going through
directories in PATH, as this is how CreateProcessW behaves.

- https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-createprocessw
- python/cpython#91558 (comment)

The technique I had used in 881456b also had the shortcoming of
assuming that a bash.exe in System32 was the WSL wrapper. But such
a file may exist on some systems without WSL, and be a bash
interpreter unrelated to WSL that may be able to run hooks.

In addition, one common situation, which was the case on CI before
a step to install a WSL distribution was added, is that WSL is
present but no WSL distributions are installed. In that situation
bash.exe is found in System32, but it can't be used to run any
hooks, because there's no actual system with a bash in it to wrap.
This was not covered before. Unlike some conditions that prevent
a WSL system from being used, such as resource exhaustion blocking
it from being started, the absence of a WSL system should probably
not fail the pytest run, for the same reason as we are trying not
to have the complete *absence* of bash.exe fail the pytest run.
Both conditions should be xfail. Fortunately, the error message
when no distribution exists is distinctive and can be checked for.

There is probably no correct and reasonable way to check LBYL-style
which executable file bash.exe resolves to by using shutil.which,
due to shutil.which and subprocess.Popen's differing seach orders
and other subtleties. So this adds code to do it EAFP-style using
subprocess.run (which itself uses Popen, so giving the same
CreateProcessW behavior). It tries to run a command with bash.exe
whose output pretty reliably shows if the system is WSL or not.

We may fail to get to the point of running that command at all, if
bash.exe is not usable, in which case the failure's details tell us
if bash.exe is absent (xfail), present as the WSL wrapper with no WSL
systems (xfail), or has some other error (not xfail, so the user can
become aware of and proably fix the problem). If we do get to that
point, then a file that is almost always present on both WSL 1 and
WSL 2 systems and almost always absent on any other system is
checked for, to distinguish whether the working bash shell operates
in a WSL system, or outside any such system as e.g. Git Bash does.

See https://superuser.com/a/1749811 on various techniques for
checking for WSL, including the /proc/sys/fs/binfmt_misc/WSLInterop
technique used here (which seems overall may be the most reliable).

Although the Windows CI runners have Git Bash, and this is even the
bash.exe that appears first in PATH (giving rise to the problem
with shutil.which detailed above), it would be a bit awkward to
test the behavior when Git Bash is actually used to run hooks on
CI, because of how Popen selects the System32 bash.exe first,
whether or not any WSL distribution is present. However, local
testing shows that when Git Bash's bash.exe is selected, all four
hook tests in the module pass, both before and after this change,
and furthermore that bash.exe is correctly detected as "native",
continuing to avoid an erroneous xfail mark in that case.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

4 participants