https://github.com/python/cpython/commit/bfd41e33fd299aa04f6202db1db535ee44cbf540
commit: bfd41e33fd299aa04f6202db1db535ee44cbf540
branch: 3.14
author: Serhiy Storchaka <[email protected]>
committer: serhiy-storchaka <[email protected]>
date: 2026-08-30T08:14:37Z
summary:
[3.14] gh-84419: Fix the execute permissions in os.stat() on Windows
(GH-155392) (GH-156630)
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.py
M Modules/posixmodule.c
diff --git a/Lib/test/test_os.py b/Lib/test/test_os.py
index 0d61be4e2d2419..7616d219bce311 100644
--- a/Lib/test/test_os.py
+++ b/Lib/test/test_os.py
@@ -3614,6 +3614,45 @@ def test_not_a_console(self):
self.assertIsNone(encoding)
[email protected](sys.platform == "win32", "Win32 specific tests")
+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 + ' ')
+
+
@support.requires_subprocess()
class PidTests(unittest.TestCase):
@unittest.skipUnless(hasattr(os, 'getppid'), "test needs os.getppid")
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 cdd6cf432d6a05..916c65721e4925 100644
--- a/Modules/posixmodule.c
+++ b/Modules/posixmodule.c
@@ -1985,6 +1985,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,
@@ -2052,12 +2060,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;
}
}
@@ -16111,12 +16127,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]