Skip to content

Commit df56cf3

Browse files
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.
1 parent 998b890 commit df56cf3

3 files changed

Lines changed: 55 additions & 6 deletions

File tree

Lib/test/test_os/test_windows.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -608,5 +608,43 @@ def cleanup():
608608
self.assertGreaterEqual(stat1.st_atime, stat2.st_atime)
609609

610610

611+
class Win32StatExecutableTests(unittest.TestCase):
612+
# gh-84419: Windows strips trailing dots and spaces from the last
613+
# component of the path, so they should be ignored when guessing
614+
# the execute permissions from the file extension.
615+
616+
SUFFIXES = ['', ' ', ' ', '.', '..', ' . .']
617+
618+
def check(self, ext, mask):
619+
filename = os_helper.TESTFN + ext
620+
create_file(filename)
621+
try:
622+
for suffix in self.SUFFIXES:
623+
with self.subTest(suffix=suffix):
624+
mode = os.stat(filename + suffix).st_mode
625+
self.assertEqual(mode & 0o111, mask)
626+
finally:
627+
os_helper.unlink(filename)
628+
629+
def test_executable_extension(self):
630+
for ext in '.exe', '.bat', '.cmd', '.com', '.EXE', '.Bat':
631+
with self.subTest(ext=ext):
632+
self.check(ext, 0o111)
633+
634+
def test_not_executable_extension(self):
635+
for ext in '.txt', '.py', '.exe.txt', '':
636+
with self.subTest(ext=ext):
637+
self.check(ext, 0)
638+
639+
def test_extended_path(self):
640+
# The \\?\ prefix disables normalization: trailing spaces and dots
641+
# are part of the file name.
642+
filename = os.path.abspath(os_helper.TESTFN + '.exe')
643+
create_file(filename)
644+
self.addCleanup(os_helper.unlink, filename)
645+
self.assertEqual(os.stat('\\\\?\\' + filename).st_mode & 0o111, 0o111)
646+
self.assertRaises(OSError, os.stat, '\\\\?\\' + filename + ' ')
647+
648+
611649
if __name__ == "__main__":
612650
unittest.main()
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Fix :func:`os.stat` on Windows: trailing dots and spaces, which are ignored
2+
by the operating system, are no longer taken into account when the execute
3+
permissions are guessed from the file extension.

Modules/posixmodule.c

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2108,12 +2108,20 @@ update_st_mode_from_path(const wchar_t *path, DWORD attr,
21082108
GetSecurityInfo, OpenThreadToken/OpenProcessToken, and
21092109
AccessCheck to check for generic read, write, and execute
21102110
access. */
2111-
const wchar_t *fileExtension = wcsrchr(path, '.');
2112-
if (fileExtension) {
2113-
if (_wcsicmp(fileExtension, L".exe") == 0 ||
2114-
_wcsicmp(fileExtension, L".bat") == 0 ||
2115-
_wcsicmp(fileExtension, L".cmd") == 0 ||
2116-
_wcsicmp(fileExtension, L".com") == 0) {
2111+
size_t len = wcslen(path);
2112+
if (wcsncmp(path, L"\\\\?\\", 4) != 0) {
2113+
/* Trailing dots and spaces are stripped from the last component
2114+
of the path, unless the \\?\ prefix disables normalization. */
2115+
while (len > 0 && (path[len - 1] == L'.' || path[len - 1] == L' ')) {
2116+
len--;
2117+
}
2118+
}
2119+
if (len >= 4) {
2120+
const wchar_t *fileExtension = path + len - 4;
2121+
if (_wcsnicmp(fileExtension, L".exe", 4) == 0 ||
2122+
_wcsnicmp(fileExtension, L".bat", 4) == 0 ||
2123+
_wcsnicmp(fileExtension, L".cmd", 4) == 0 ||
2124+
_wcsnicmp(fileExtension, L".com", 4) == 0) {
21172125
result->st_mode |= 0111;
21182126
}
21192127
}

0 commit comments

Comments
 (0)