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..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, @@ -2108,12 +2116,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; } } @@ -16711,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)