From e00818d1f9544a17b93603a0403f5f7b68da1009 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sun, 30 Aug 2026 10:39:28 +0300 Subject: [PATCH] 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. (cherry picked from commit b7daede668ad42d11c28986f147eb6b2f68ef926) --- Lib/test/test_os/test_windows.py | 38 +++++++++++++++++++ ...-08-08-21-40-00.gh-issue-84419.statexe.rst | 3 ++ Modules/posixmodule.c | 34 +++++++++++------ 3 files changed, 63 insertions(+), 12 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-08-08-21-40-00.gh-issue-84419.statexe.rst diff --git a/Lib/test/test_os/test_windows.py b/Lib/test/test_os/test_windows.py index f1c6283f60d35ed..9f31c50a67cb00b 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 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 0b42b059541d351..c36e320fdb626e8 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)