diff --git a/Lib/test/test_os/test_windows.py b/Lib/test/test_os/test_windows.py index b21dd8a4dca660..425bd480e2ad3a 100644 --- a/Lib/test/test_os/test_windows.py +++ b/Lib/test/test_os/test_windows.py @@ -454,6 +454,59 @@ def test_unlink_removes_junction(self): self.assertFalse(os.path.exists(self.junction)) +class Win32ChmodTests(unittest.TestCase): + # gh-140774: os.chmod(follow_symlinks=True) and os.fchmod() go through + # SetFileInformationByHandle(), which interprets FileAttributes == 0 as + # "leave the attributes unchanged". Clearing the read-only flag of a + # file with no other attribute set must substitute FILE_ATTRIBUTE_NORMAL + # for 0 to take effect. + + def setUp(self): + self.fname = os.path.abspath(os_helper.TESTFN) + self.addCleanup(os_helper.unlink, self.fname) + self.addCleanup(self._make_writable) + create_file(self.fname) + + def _make_writable(self): + try: + os.chmod(self.fname, stat.S_IWRITE | stat.S_IREAD, + follow_symlinks=False) + except OSError: + pass + + def _set_readonly_only_attribute(self): + # Make FILE_ATTRIBUTE_READONLY the only attribute of the file + # (in particular, clear FILE_ATTRIBUTE_ARCHIVE). + ctypes = import_helper.import_module('ctypes') + kernel32 = ctypes.WinDLL('Kernel32.dll', use_last_error=True) + if not kernel32.SetFileAttributesW(self.fname, + stat.FILE_ATTRIBUTE_READONLY): + raise ctypes.WinError(ctypes.get_last_error()) + attributes = os.stat(self.fname).st_file_attributes + if attributes != stat.FILE_ATTRIBUTE_READONLY: + self.skipTest('file system reports extra file attributes ' + f'{attributes:#x}') + + def _assert_writable(self): + self.assertEqual(os.stat(self.fname).st_file_attributes + & stat.FILE_ATTRIBUTE_READONLY, 0) + + def test_chmod_clears_readonly_without_other_attributes(self): + self._set_readonly_only_attribute() + os.chmod(self.fname, stat.S_IWRITE | stat.S_IREAD, + follow_symlinks=True) + self._assert_writable() + + def test_fchmod_clears_readonly_without_other_attributes(self): + fd = os.open(self.fname, os.O_RDWR) + try: + self._set_readonly_only_attribute() + os.fchmod(fd, stat.S_IWRITE | stat.S_IREAD) + finally: + os.close(fd) + self._assert_writable() + + class Win32NtTests(unittest.TestCase): def test_getfinalpathname_handles(self): nt = import_helper.import_module('nt') diff --git a/Misc/NEWS.d/next/Windows/2026-07-29-13-05-00.gh-issue-140774.wA3ttr.rst b/Misc/NEWS.d/next/Windows/2026-07-29-13-05-00.gh-issue-140774.wA3ttr.rst new file mode 100644 index 00000000000000..aaf120efb992e7 --- /dev/null +++ b/Misc/NEWS.d/next/Windows/2026-07-29-13-05-00.gh-issue-140774.wA3ttr.rst @@ -0,0 +1,3 @@ +Fix :func:`os.chmod` with ``follow_symlinks=True``, :func:`os.fchmod` and +:meth:`pathlib.Path.chmod` on Windows: clearing the read-only attribute of a +file which has no other attribute set is no longer silently ignored. diff --git a/Modules/posixmodule.c b/Modules/posixmodule.c index f754d0e18b5fb0..c84f4f414deab2 100644 --- a/Modules/posixmodule.c +++ b/Modules/posixmodule.c @@ -4021,6 +4021,12 @@ win32_hchmod(HANDLE hfile, int mode) } if (mode & _S_IWRITE) { info.FileAttributes &= ~FILE_ATTRIBUTE_READONLY; + if (info.FileAttributes == 0) { + /* SetFileInformationByHandle() interprets FileAttributes == 0 + as "leave the attributes unchanged"; FILE_ATTRIBUTE_NORMAL + is the documented way to clear all attributes. */ + info.FileAttributes = FILE_ATTRIBUTE_NORMAL; + } } else { info.FileAttributes |= FILE_ATTRIBUTE_READONLY;