From df56cf3e58968707033a8413b3ef57ec73c853ef Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sat, 8 Aug 2026 20:38:11 +0300 Subject: [PATCH 1/2] gh-84419: Fix the execute permissions in os.stat() on Windows 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. --- Lib/test/test_os/test_windows.py | 38 +++++++++++++++++++ ...-08-08-21-40-00.gh-issue-84419.statexe.rst | 3 ++ Modules/posixmodule.c | 20 +++++++--- 3 files changed, 55 insertions(+), 6 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 b21dd8a4dca6609..5f949d86610134b 100644 --- a/Lib/test/test_os/test_windows.py +++ b/Lib/test/test_os/test_windows.py @@ -608,5 +608,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 db65d5862440655..c92e455feea8efc 100644 --- a/Modules/posixmodule.c +++ b/Modules/posixmodule.c @@ -2108,12 +2108,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 (wcsncmp(path, L"\\\\?\\", 4) != 0) { + /* Trailing dots and spaces are stripped from the last component + of the path, unless the \\?\ prefix disables normalization. */ + 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; } } From c24db89476956b5976ca2819826703bdd5882127 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sat, 8 Aug 2026 20:53:28 +0300 Subject: [PATCH 2/2] Reuse is_extended_path() --- Modules/posixmodule.c | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/Modules/posixmodule.c b/Modules/posixmodule.c index c92e455feea8efc..cf87aa49e2faf76 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, FILE_BASIC_INFO* basic_info, @@ -2109,9 +2117,9 @@ update_st_mode_from_path(const wchar_t *path, DWORD attr, AccessCheck to check for generic read, write, and execute access. */ size_t len = wcslen(path); - if (wcsncmp(path, L"\\\\?\\", 4) != 0) { + if (!is_extended_path(path)) { /* Trailing dots and spaces are stripped from the last component - of the path, unless the \\?\ prefix disables normalization. */ + of the path. */ while (len > 0 && (path[len - 1] == L'.' || path[len - 1] == L' ')) { len--; } @@ -16719,12 +16727,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)