diff --git a/Lib/subprocess.py b/Lib/subprocess.py index d38cc756ec479f2..466f2c10a2c3d4d 100644 --- a/Lib/subprocess.py +++ b/Lib/subprocess.py @@ -1629,21 +1629,31 @@ def _execute_child(self, args, executable, preexec_fn, close_fds, assert not pass_fds, "pass_fds not supported on Windows." if isinstance(args, str): - pass + # Filename is the program only. Later arguments can + # hold secrets. A leading quote ends at the next quote. + # Otherwise stop at the first space. + if args[:1] == '"': + end = args.find('"', 1) + orig_filename = args[1:end] if end != -1 else args + else: + orig_filename = args.split(' ', 1)[0] elif isinstance(args, bytes): if shell: raise TypeError('bytes args is not allowed on Windows') + orig_filename = os.fsdecode(args) args = list2cmdline([args]) elif isinstance(args, os.PathLike): if shell: raise TypeError('path-like args is not allowed when ' 'shell is true') + orig_filename = os.fsdecode(args) args = list2cmdline([args]) else: + args = list(args) + orig_filename = os.fsdecode(args[0]) if args else None args = list2cmdline(args) - if executable is not None: - executable = os.fsdecode(executable) + orig_filename = executable = os.fsdecode(executable) # Process startup details if startupinfo is None: @@ -1725,6 +1735,19 @@ def _execute_child(self, args, executable, preexec_fn, close_fds, env, cwd, startupinfo) + except OSError as e: + # gh-119646: POSIX already puts the attempted path on + # OSError.filename. Windows CreateProcess did not, so + # failures (missing exe, WSL paths, invalid cwd) were + # reported without naming the command. + if e.filename is None: + # ERROR_DIRECTORY (267): CreateProcess rejected cwd. + if cwd is not None and e.winerror == 267: + name = cwd + else: + name = orig_filename + raise type(e)(e.errno, e.strerror, name, e.winerror) from None + raise finally: # Child is launched. Close the parent's copy of those pipe # handles that only the child should have open. You need diff --git a/Lib/test/test_subprocess.py b/Lib/test/test_subprocess.py index fc94b9a972828c1..dedd3cc0e9e8675 100644 --- a/Lib/test/test_subprocess.py +++ b/Lib/test/test_subprocess.py @@ -1797,13 +1797,28 @@ def test_failed_child_execute_fd_leak(self): fds_after_exception = os.listdir(fd_directory) self.assertEqual(fds_before_popen, fds_after_exception) - @unittest.skipIf(mswindows, "behavior currently not supported on Windows") def test_file_not_found_includes_filename(self): + missing = (r'C:\opt\nonexistent_binary' if mswindows + else '/opt/nonexistent_binary') with self.assertRaises(FileNotFoundError) as c: - subprocess.call(['/opt/nonexistent_binary', 'with', 'some', 'args']) - self.assertEqual(c.exception.filename, '/opt/nonexistent_binary') + subprocess.call([missing, 'with', 'some', 'args']) + self.assertEqual(c.exception.filename, missing) + + def test_args_filter_iterable(self): + # gh-119646: Windows used to index args[0] before list2cmdline. + # test_faulthandler.test_sys_xoptions passes a filter() object. + args = filter(None, (sys.executable, "-c", "import sys; sys.exit(17)")) + self.assertEqual(subprocess.call(args), 17) + + def test_file_not_found_includes_filename_from_iterable(self): + missing = (r'C:\opt\nonexistent_binary' if mswindows + else '/opt/nonexistent_binary') + args = filter(None, (missing, "with", "some", "args")) + with self.assertRaises(FileNotFoundError) as c: + subprocess.call(args) + self.assertEqual(c.exception.filename, missing) - @unittest.skipIf(mswindows, "behavior currently not supported on Windows") + @unittest.skipIf(mswindows, "Windows reports NotADirectoryError (WinError 267)") def test_file_not_found_with_bad_cwd(self): with self.assertRaises(FileNotFoundError) as c: subprocess.Popen(['exit', '0'], cwd='/some/nonexistent/directory') @@ -3718,6 +3733,34 @@ def test_vfork_used_when_expected(self): @unittest.skipUnless(mswindows, "Windows specific tests") class Win32ProcessTestCase(BaseTestCase): + def test_createprocess_bad_cwd_includes_filename(self): + # gh-119646: invalid cwd should appear on OSError.filename. + missing_cwd = r'C:\some\nonexistent\directory' + with self.assertRaises(OSError) as c: + subprocess.Popen([sys.executable, '-c', 'pass'], cwd=missing_cwd) + self.assertEqual(c.exception.filename, missing_cwd) + self.assertEqual(c.exception.winerror, 267) + + def test_command_string_filename_omits_later_args(self): + # gh-119646: a command-line string must not put later arguments + # on OSError.filename. Those arguments can hold secrets. + missing = r'C:\opt\nonexistent_binary' + secret = 'NOT-A-REAL-SECRET' + quoted = r'C:\Program Files\nonexistent_binary' + cases = [ + (missing, missing), + (f'{missing} --token {secret}', missing), + (f'"{missing}" --token {secret}', missing), + (f'"{quoted}" --token {secret}', quoted), + ] + for command, expected in cases: + with self.subTest(command=command): + with self.assertRaises(FileNotFoundError) as c: + subprocess.call(command) + self.assertEqual(c.exception.filename, expected) + self.assertNotIn(secret, c.exception.filename or '') + self.assertNotIn(secret, str(c.exception)) + def test_startupinfo(self): # startupinfo argument # We uses hardcoded constants, because we do not want to diff --git a/Misc/NEWS.d/next/Library/2026-09-17-17-30-00.gh-issue-119646.kP8nQm.rst b/Misc/NEWS.d/next/Library/2026-09-17-17-30-00.gh-issue-119646.kP8nQm.rst new file mode 100644 index 000000000000000..e546511677a93e6 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-09-17-17-30-00.gh-issue-119646.kP8nQm.rst @@ -0,0 +1,2 @@ +On Windows, :exc:`OSError` from :mod:`subprocess` now includes the attempted +executable or working directory in ``filename``.