https://github.com/python/cpython/commit/b7daede668ad42d11c28986f147eb6b2f68ef926
commit: b7daede668ad42d11c28986f147eb6b2f68ef926
branch: main
author: Serhiy Storchaka <[email protected]>
committer: serhiy-storchaka <[email protected]>
date: 2026-08-30T10:39:28+03:00
summary:
gh-84419: Fix the execute permissions in os.stat() on Windows (GH-155392)
Windows strips trailing dots and spaces from the last component of the path,
so os.stat('spam.bat ') opened the same file as os.stat('spam.bat'), but did
not set the execute permissions in st_mode, because the extension did not
match. They are now ignored, unless the \\?\ prefix disables the path
normalization.
files:
A Misc/NEWS.d/next/Library/2026-08-08-21-40-00.gh-issue-84419.statexe.rst
M Lib/test/test_os/test_windows.py
M Modules/posixmodule.c
diff --git a/Lib/test/test_os/test_windows.py b/Lib/test/test_os/test_windows.py
index 3fb8454cddfde93..b0be69cc64b94b3 100644
--- a/Lib/test/test_os/test_windows.py
+++ b/Lib/test/test_os/test_windows.py
@@ -646,5 +646,43 @@ def test_not_a_console(self):
self.assertIsNone(encoding)
+class Win32StatExecutableTests(unittest.TestCase):
+ # gh-84419: Windows strips trailing dots and spaces from the last
+ # component of the path, so they should be ignored when guessing
+ # the execute permissions from the file extension.
+
+ SUFFIXES = ['', ' ', ' ', '.', '..', ' . .']
+
+ def check(self, ext, mask):
+ filename = os_helper.TESTFN + ext
+ create_file(filename)
+ try:
+ for suffix in self.SUFFIXES:
+ with self.subTest(suffix=suffix):
+ mode = os.stat(filename + suffix).st_mode
+ self.assertEqual(mode & 0o111, mask)
+ finally:
+ os_helper.unlink(filename)
+
+ def test_executable_extension(self):
+ for ext in '.exe', '.bat', '.cmd', '.com', '.EXE', '.Bat':
+ with self.subTest(ext=ext):
+ self.check(ext, 0o111)
+
+ def test_not_executable_extension(self):
+ for ext in '.txt', '.py', '.exe.txt', '':
+ with self.subTest(ext=ext):
+ self.check(ext, 0)
+
+ def test_extended_path(self):
+ # The \\?\ prefix disables normalization: trailing spaces and dots
+ # are part of the file name.
+ filename = os.path.abspath(os_helper.TESTFN + '.exe')
+ create_file(filename)
+ self.addCleanup(os_helper.unlink, filename)
+ self.assertEqual(os.stat('\\\\?\\' + filename).st_mode & 0o111, 0o111)
+ self.assertRaises(OSError, os.stat, '\\\\?\\' + filename + ' ')
+
+
if __name__ == "__main__":
unittest.main()
diff --git
a/Misc/NEWS.d/next/Library/2026-08-08-21-40-00.gh-issue-84419.statexe.rst
b/Misc/NEWS.d/next/Library/2026-08-08-21-40-00.gh-issue-84419.statexe.rst
new file mode 100644
index 000000000000000..03b90b42e8c3e63
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2026-08-08-21-40-00.gh-issue-84419.statexe.rst
@@ -0,0 +1,3 @@
+Fix :func:`os.stat` on Windows: trailing dots and spaces, which are ignored
+by the operating system, are no longer taken into account when the execute
+permissions are guessed from the file extension.
diff --git a/Modules/posixmodule.c b/Modules/posixmodule.c
index a114a617918f7ce..847237142854da1 100644
--- a/Modules/posixmodule.c
+++ b/Modules/posixmodule.c
@@ -2021,6 +2021,14 @@ win32_wchdir(LPCWSTR path)
#define HAVE_STRUCT_STAT_ST_FILE_ATTRIBUTES 1
#define HAVE_STRUCT_STAT_ST_REPARSE_TAG 1
+/* The \\?\ prefix disables the path normalization, in particular
+ stripping of trailing dots and spaces. */
+static int
+is_extended_path(const wchar_t *path)
+{
+ return wcsncmp(path, L"\\\\?\\", 4) == 0;
+}
+
static void
find_data_to_file_info(WIN32_FIND_DATAW *pFileData,
FILE_BASIC_INFO* basic_info,
@@ -2094,12 +2102,20 @@ update_st_mode_from_path(const wchar_t *path, DWORD
attr,
GetSecurityInfo, OpenThreadToken/OpenProcessToken, and
AccessCheck to check for generic read, write, and execute
access. */
- const wchar_t *fileExtension = wcsrchr(path, '.');
- if (fileExtension) {
- if (_wcsicmp(fileExtension, L".exe") == 0 ||
- _wcsicmp(fileExtension, L".bat") == 0 ||
- _wcsicmp(fileExtension, L".cmd") == 0 ||
- _wcsicmp(fileExtension, L".com") == 0) {
+ size_t len = wcslen(path);
+ if (!is_extended_path(path)) {
+ /* Trailing dots and spaces are stripped from the last component
+ of the path. */
+ while (len > 0 && (path[len - 1] == L'.' || path[len - 1] == L'
')) {
+ len--;
+ }
+ }
+ if (len >= 4) {
+ const wchar_t *fileExtension = path + len - 4;
+ if (_wcsnicmp(fileExtension, L".exe", 4) == 0 ||
+ _wcsnicmp(fileExtension, L".bat", 4) == 0 ||
+ _wcsnicmp(fileExtension, L".cmd", 4) == 0 ||
+ _wcsnicmp(fileExtension, L".com", 4) == 0) {
result->st_mode |= 0111;
}
}
@@ -16718,12 +16734,6 @@ static PyType_Spec DirEntryType_spec = {
#ifdef MS_WINDOWS
-static int
-is_extended_path(const wchar_t *path)
-{
- return wcsncmp(path, L"\\\\?\\", 4) == 0;
-}
-
static wchar_t *
join_path_filenameW(const wchar_t *path_wide, const wchar_t *filename,
int normalize)
_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]