https://github.com/python/cpython/commit/1b4d7b5fc8b7db30d0df122c59d5e2366327251d
commit: 1b4d7b5fc8b7db30d0df122c59d5e2366327251d
branch: 3.15
author: Serhiy Storchaka <[email protected]>
committer: serhiy-storchaka <[email protected]>
date: 2026-10-09T20:57:47+03:00
summary:
[3.15] gh-84419: Fix the execute permissions in os.stat() on Windows
(GH-155392) (GH-156629)
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.
(cherry picked from commit b7daede668ad42d11c28986f147eb6b2f68ef926)
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 f1c6283f60d35e..9f31c50a67cb00 100644
--- a/Lib/test/test_os/test_windows.py
+++ b/Lib/test/test_os/test_windows.py
@@ -601,5 +601,43 @@ def cleanup():
self.assertGreaterEqual(stat1.st_atime, stat2.st_atime)
+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 00000000000000..03b90b42e8c3e6
--- /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 4b807644b153cd..c81ec259d2bee9 100644
--- a/Modules/posixmodule.c
+++ b/Modules/posixmodule.c
@@ -2035,6 +2035,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,
BY_HANDLE_FILE_INFORMATION *info,
@@ -2102,12 +2110,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;
}
}
@@ -16675,12 +16691,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]