From c0cfad71387dc5bb9ba8c229f7d869746abd10e4 Mon Sep 17 00:00:00 2001 From: Pat Date: Thu, 17 Sep 2026 17:16:50 -0500 Subject: [PATCH 1/3] gh-119646: Include the attempted path on Windows subprocess OSError Co-authored-by: Cursor --- Lib/subprocess.py | 21 ++++++++++++++++--- Lib/test/test_subprocess.py | 17 +++++++++++---- ...-09-17-17-30-00.gh-issue-119646.kP8nQm.rst | 2 ++ 3 files changed, 33 insertions(+), 7 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-09-17-17-30-00.gh-issue-119646.kP8nQm.rst diff --git a/Lib/subprocess.py b/Lib/subprocess.py index d38cc756ec479f..f07f2e0f82213d 100644 --- a/Lib/subprocess.py +++ b/Lib/subprocess.py @@ -1629,21 +1629,23 @@ 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 + orig_filename = args 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: + 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 +1727,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 fc94b9a972828c..53df16292f5a98 100644 --- a/Lib/test/test_subprocess.py +++ b/Lib/test/test_subprocess.py @@ -1797,13 +1797,14 @@ 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) - @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 +3719,14 @@ 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_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 00000000000000..e546511677a93e --- /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``. From 3ae4021450b0dcf40486c69466e1987cddb5f38c Mon Sep 17 00:00:00 2001 From: Pat Date: Thu, 17 Sep 2026 17:33:54 -0500 Subject: [PATCH 2/3] gh-119646: Materialize iterable args before reading the executable name Co-authored-by: Cursor --- Lib/subprocess.py | 1 + Lib/test/test_subprocess.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/Lib/subprocess.py b/Lib/subprocess.py index f07f2e0f82213d..dee87695182cd5 100644 --- a/Lib/subprocess.py +++ b/Lib/subprocess.py @@ -1642,6 +1642,7 @@ def _execute_child(self, args, executable, preexec_fn, close_fds, 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: diff --git a/Lib/test/test_subprocess.py b/Lib/test/test_subprocess.py index 53df16292f5a98..2bbd8adede8f73 100644 --- a/Lib/test/test_subprocess.py +++ b/Lib/test/test_subprocess.py @@ -1804,6 +1804,20 @@ def test_file_not_found_includes_filename(self): 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, "Windows reports NotADirectoryError (WinError 267)") def test_file_not_found_with_bad_cwd(self): with self.assertRaises(FileNotFoundError) as c: From 301b1478e61ae5be34d9cc38cebaeaa322ad880b Mon Sep 17 00:00:00 2001 From: Pat Date: Tue, 22 Sep 2026 15:50:09 -0500 Subject: [PATCH 3/3] gh-119646: Limit Windows OSError.filename to the program path A command-line string could put later arguments on the traceback. Co-authored-by: Cursor --- Lib/subprocess.py | 9 ++++++++- Lib/test/test_subprocess.py | 20 ++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/Lib/subprocess.py b/Lib/subprocess.py index dee87695182cd5..466f2c10a2c3d4 100644 --- a/Lib/subprocess.py +++ b/Lib/subprocess.py @@ -1629,7 +1629,14 @@ def _execute_child(self, args, executable, preexec_fn, close_fds, assert not pass_fds, "pass_fds not supported on Windows." if isinstance(args, str): - orig_filename = args + # 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') diff --git a/Lib/test/test_subprocess.py b/Lib/test/test_subprocess.py index 2bbd8adede8f73..dedd3cc0e9e867 100644 --- a/Lib/test/test_subprocess.py +++ b/Lib/test/test_subprocess.py @@ -3741,6 +3741,26 @@ def test_createprocess_bad_cwd_includes_filename(self): 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