Skip to content

Commit e00818d

Browse files
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 b7daede)
1 parent 79b3919 commit e00818d

3 files changed

Lines changed: 63 additions & 12 deletions

File tree

‎Lib/test/test_os/test_windows.py‎

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

603603

604+
class Win32StatExecutableTests(unittest.TestCase):
605+
# gh-84419: Windows strips trailing dots and spaces from the last
606+
# component of the path, so they should be ignored when guessing
607+
# the execute permissions from the file extension.
608+
609+
SUFFIXES = ['', ' ', ' ', '.', '..', ' . .']
610+
611+
def check(self, ext, mask):
612+
filename = os_helper.TESTFN + ext
613+
create_file(filename)
614+
try:
615+
for suffix in self.SUFFIXES:
616+
with self.subTest(suffix=suffix):
617+
mode = os.stat(filename + suffix).st_mode
618+
self.assertEqual(mode & 0o111, mask)
619+
finally:
620+
os_helper.unlink(filename)
621+
622+
def test_executable_extension(self):
623+
for ext in '.exe', '.bat', '.cmd', '.com', '.EXE', '.Bat':
624+
with self.subTest(ext=ext):
625+
self.check(ext, 0o111)
626+
627+
def test_not_executable_extension(self):
628+
for ext in '.txt', '.py', '.exe.txt', '':
629+
with self.subTest(ext=ext):
630+
self.check(ext, 0)
631+
632+
def test_extended_path(self):
633+
# The \\?\ prefix disables normalization: trailing spaces and dots
634+
# are part of the file name.
635+
filename = os.path.abspath(os_helper.TESTFN + '.exe')
636+
create_file(filename)
637+
self.addCleanup(os_helper.unlink, filename)
638+
self.assertEqual(os.stat('\\\\?\\' + filename).st_mode & 0o111, 0o111)
639+
self.assertRaises(OSError, os.stat, '\\\\?\\' + filename + ' ')
640+
641+
604642
if __name__ == "__main__":
605643
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: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2035,6 +2035,14 @@ win32_wchdir(LPCWSTR path)
20352035
#define HAVE_STRUCT_STAT_ST_FILE_ATTRIBUTES 1
20362036
#define HAVE_STRUCT_STAT_ST_REPARSE_TAG 1
20372037

2038+
/* The \\?\ prefix disables the path normalization, in particular
2039+
stripping of trailing dots and spaces. */
2040+
static int
2041+
is_extended_path(const wchar_t *path)
2042+
{
2043+
return wcsncmp(path, L"\\\\?\\", 4) == 0;
2044+
}
2045+
20382046
static void
20392047
find_data_to_file_info(WIN32_FIND_DATAW *pFileData,
20402048
BY_HANDLE_FILE_INFORMATION *info,
@@ -2102,12 +2110,20 @@ update_st_mode_from_path(const wchar_t *path, DWORD attr,
21022110
GetSecurityInfo, OpenThreadToken/OpenProcessToken, and
21032111
AccessCheck to check for generic read, write, and execute
21042112
access. */
2105-
const wchar_t *fileExtension = wcsrchr(path, '.');
2106-
if (fileExtension) {
2107-
if (_wcsicmp(fileExtension, L".exe") == 0 ||
2108-
_wcsicmp(fileExtension, L".bat") == 0 ||
2109-
_wcsicmp(fileExtension, L".cmd") == 0 ||
2110-
_wcsicmp(fileExtension, L".com") == 0) {
2113+
size_t len = wcslen(path);
2114+
if (!is_extended_path(path)) {
2115+
/* Trailing dots and spaces are stripped from the last component
2116+
of the path. */
2117+
while (len > 0 && (path[len - 1] == L'.' || path[len - 1] == L' ')) {
2118+
len--;
2119+
}
2120+
}
2121+
if (len >= 4) {
2122+
const wchar_t *fileExtension = path + len - 4;
2123+
if (_wcsnicmp(fileExtension, L".exe", 4) == 0 ||
2124+
_wcsnicmp(fileExtension, L".bat", 4) == 0 ||
2125+
_wcsnicmp(fileExtension, L".cmd", 4) == 0 ||
2126+
_wcsnicmp(fileExtension, L".com", 4) == 0) {
21112127
result->st_mode |= 0111;
21122128
}
21132129
}
@@ -16675,12 +16691,6 @@ static PyType_Spec DirEntryType_spec = {
1667516691

1667616692
#ifdef MS_WINDOWS
1667716693

16678-
static int
16679-
is_extended_path(const wchar_t *path)
16680-
{
16681-
return wcsncmp(path, L"\\\\?\\", 4) == 0;
16682-
}
16683-
1668416694
static wchar_t *
1668516695
join_path_filenameW(const wchar_t *path_wide, const wchar_t *filename,
1668616696
int normalize)

0 commit comments

Comments
 (0)