From d57be548267cd5a002c317e446f5909ff768999d Mon Sep 17 00:00:00 2001 From: Victor Stinner Date: Mon, 2 Mar 2026 19:17:33 +0100 Subject: [PATCH 1/3] gh-145335: Fix os functions when passing fd -1 as path os.listdir(-1) and os.scandir(-1) now fail with OSError(errno.EBADF) rather than listing the current directory. os.listxattr(-1) now fails with OSError(errno.EBADF) rather than listing extended attributes of the current directory. --- Lib/test/test_os/test_os.py | 69 ++++++++++++++++++- ...-03-02-20-08-09.gh-issue-145335.lVTBvd.rst | 5 ++ Modules/posixmodule.c | 67 +++++++++--------- 3 files changed, 107 insertions(+), 34 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-03-02-20-08-09.gh-issue-145335.lVTBvd.rst diff --git a/Lib/test/test_os/test_os.py b/Lib/test/test_os/test_os.py index 1f241609da80cd..19ac2614548b78 100644 --- a/Lib/test/test_os/test_os.py +++ b/Lib/test/test_os/test_os.py @@ -2778,15 +2778,80 @@ def test_fpathconf_bad_fd(self): self.check(os.pathconf, "PC_NAME_MAX") self.check(os.fpathconf, "PC_NAME_MAX") + @contextlib.contextmanager + def check_for_ebadf(self, errnos=(errno.EBADF,)): + with self.assertRaises(OSError) as ctx: + yield + self.assertIn(ctx.exception.errno, errnos) + @unittest.skipUnless(hasattr(os, 'pathconf'), 'test needs os.pathconf()') + @unittest.skipUnless(os.pathconf in os.supports_fd, + 'needs fpathconf()') @unittest.skipIf( support.linked_to_musl(), 'musl fpathconf ignores the file descriptor and returns a constant', ) def test_pathconf_negative_fd_uses_fd_semantics(self): - with self.assertRaises(OSError) as ctx: + with self.check_for_ebadf(): os.pathconf(-1, 1) - self.assertEqual(ctx.exception.errno, errno.EBADF) + + @support.subTests("fd", [-1, -5]) + def test_negative_fd_ebadf(self, fd): + with self.check_for_ebadf(): + os.stat(fd) + if hasattr(os, "statx"): + with self.check_for_ebadf(): + os.statx(fd, mask=0) + if os.chdir in os.supports_fd: + with self.check_for_ebadf(): + os.chdir(fd) + if os.chmod in os.supports_fd: + with self.check_for_ebadf(): + os.chmod(fd, 0o777) + if hasattr(os, "chown") and os.chown in os.supports_fd: + with self.check_for_ebadf(): + os.chown(fd, 0, 0) + if os.listdir in os.supports_fd: + with self.check_for_ebadf(): + os.listdir(fd) + if support.MS_WINDOWS: + with self.check_for_ebadf(): + os._path_exists(fd) + with self.check_for_ebadf(): + os._path_lexists(fd) + with self.check_for_ebadf(): + os._path_isdir(fd) + with self.check_for_ebadf(): + os._path_isfile(fd) + with self.check_for_ebadf(): + os._path_islink(fd) + with self.check_for_ebadf(): + os._path_isjunction(fd) + if os.utime in os.supports_fd: + with self.check_for_ebadf(): + os.utime(fd, (0, 0)) + if hasattr(os, "execve") and os.execve in os.supports_fd: + # glibc fails with EINVAL, musl fails with EBADF + with self.check_for_ebadf(errnos=(errno.EBADF, errno.EINVAL)): + os.execve(fd, [sys.executable, "-c", "pass"], os.environ) + if hasattr(os, "truncate") and os.truncate in os.supports_fd: + with self.check_for_ebadf(): + os.truncate(fd, 0) + if hasattr(os, 'statvfs') and os.statvfs in os.supports_fd: + with self.check_for_ebadf(): + os.statvfs(fd) + if hasattr(os, "setxattr"): + with self.check_for_ebadf(): + os.getxattr(fd, b"user.test") + with self.check_for_ebadf(): + os.setxattr(fd, b"user.test", b"1") + with self.check_for_ebadf(): + os.removexattr(fd, b"user.test") + with self.check_for_ebadf(): + os.listxattr(fd) + if os.scandir in os.supports_fd: + with self.check_for_ebadf(): + os.scandir(fd) @unittest.skipUnless(hasattr(os, 'ftruncate'), 'test needs os.ftruncate()') def test_ftruncate(self): diff --git a/Misc/NEWS.d/next/Library/2026-03-02-20-08-09.gh-issue-145335.lVTBvd.rst b/Misc/NEWS.d/next/Library/2026-03-02-20-08-09.gh-issue-145335.lVTBvd.rst new file mode 100644 index 00000000000000..53033b06c2fae0 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-03-02-20-08-09.gh-issue-145335.lVTBvd.rst @@ -0,0 +1,5 @@ +``os.listdir(-1)`` and ``os.scandir(-1)`` now fail with +``OSError(errno.EBADF)`` rather than listing the current directory. +``os.listxattr(-1)`` now fails with ``OSError(errno.EBADF)`` rather than +listing extended attributes of the current directory. Patch by Victor +Stinner. diff --git a/Modules/posixmodule.c b/Modules/posixmodule.c index b82f08e7dc4291..aa3d682a19bc9c 100644 --- a/Modules/posixmodule.c +++ b/Modules/posixmodule.c @@ -1638,10 +1638,10 @@ dir_fd_and_fd_invalid(const char *function_name, int dir_fd, int fd) } static int -fd_and_follow_symlinks_invalid(const char *function_name, int fd, +fd_and_follow_symlinks_invalid(const char *function_name, int is_fd, int follow_symlinks) { - if ((fd >= 0) && (!follow_symlinks)) { + if (is_fd && (!follow_symlinks)) { PyErr_Format(PyExc_ValueError, "%s: cannot use fd and follow_symlinks together", function_name); @@ -2880,12 +2880,13 @@ posix_do_stat(PyObject *module, const char *function_name, path_t *path, if (path_and_dir_fd_invalid("stat", path, dir_fd) || dir_fd_and_fd_invalid("stat", dir_fd, path->fd) || - fd_and_follow_symlinks_invalid("stat", path->fd, follow_symlinks)) + fd_and_follow_symlinks_invalid("stat", path->is_fd, follow_symlinks)) return NULL; Py_BEGIN_ALLOW_THREADS - if (path->fd != -1) + if (path->is_fd) { result = FSTAT(path->fd, &st); + } #ifdef MS_WINDOWS else if (follow_symlinks) result = win32_stat(path->wide, &st); @@ -3647,7 +3648,7 @@ os_statx_impl(PyObject *module, path_t *path, unsigned int mask, int flags, { if (path_and_dir_fd_invalid("statx", path, dir_fd) || dir_fd_and_fd_invalid("statx", dir_fd, path->fd) || - fd_and_follow_symlinks_invalid("statx", path->fd, follow_symlinks)) { + fd_and_follow_symlinks_invalid("statx", path->is_fd, follow_symlinks)) { return NULL; } @@ -3677,7 +3678,7 @@ os_statx_impl(PyObject *module, path_t *path, unsigned int mask, int flags, int result; Py_BEGIN_ALLOW_THREADS - if (path->fd != -1) { + if (path->is_fd) { result = statx(path->fd, "", flags | AT_EMPTY_PATH, mask, &v->stx); } else { @@ -3934,7 +3935,7 @@ os_chdir_impl(PyObject *module, path_t *path) result = !win32_wchdir(path->wide); #else #ifdef HAVE_FCHDIR - if (path->fd != -1) + if (path->is_fd) result = fchdir(path->fd); else #endif @@ -4090,7 +4091,7 @@ os_chmod_impl(PyObject *module, path_t *path, int mode, int dir_fd, #ifdef MS_WINDOWS result = 0; Py_BEGIN_ALLOW_THREADS - if (path->fd != -1) { + if (path->is_fd) { result = win32_fchmod(path->fd, mode); } else if (follow_symlinks) { @@ -4113,8 +4114,9 @@ os_chmod_impl(PyObject *module, path_t *path, int mode, int dir_fd, #else /* MS_WINDOWS */ Py_BEGIN_ALLOW_THREADS #ifdef HAVE_FCHMOD - if (path->fd != -1) + if (path->is_fd) { result = fchmod(path->fd, mode); + } else #endif /* HAVE_CHMOD */ #ifdef HAVE_LCHMOD @@ -4511,7 +4513,7 @@ os_chown_impl(PyObject *module, path_t *path, uid_t uid, gid_t gid, return NULL; #endif if (dir_fd_and_fd_invalid("chown", dir_fd, path->fd) || - fd_and_follow_symlinks_invalid("chown", path->fd, follow_symlinks)) + fd_and_follow_symlinks_invalid("chown", path->is_fd, follow_symlinks)) return NULL; if (PySys_Audit("os.chown", "OIIi", path->object, uid, gid, @@ -4521,7 +4523,7 @@ os_chown_impl(PyObject *module, path_t *path, uid_t uid, gid_t gid, Py_BEGIN_ALLOW_THREADS #ifdef HAVE_FCHOWN - if (path->fd != -1) + if (path->is_fd) result = fchown(path->fd, uid, gid); else #endif @@ -4999,7 +5001,7 @@ _posix_listdir(path_t *path, PyObject *list) errno = 0; #ifdef HAVE_FDOPENDIR - if (path->fd != -1) { + if (path->is_fd) { if (HAVE_FDOPENDIR_RUNTIME) { /* closedir() closes the FD, so we duplicate it */ fd = _Py_dup(path->fd); @@ -5898,7 +5900,7 @@ _testFileExists(path_t *path, BOOL followLinks) } Py_BEGIN_ALLOW_THREADS - if (path->fd != -1) { + if (path->is_fd) { HANDLE hfile = _Py_get_osfhandle_noraise(path->fd); if (hfile != INVALID_HANDLE_VALUE) { if (GetFileType(hfile) != FILE_TYPE_UNKNOWN || !GetLastError()) { @@ -5924,7 +5926,7 @@ _testFileType(path_t *path, int testedType) } Py_BEGIN_ALLOW_THREADS - if (path->fd != -1) { + if (path->is_fd) { HANDLE hfile = _Py_get_osfhandle_noraise(path->fd); if (hfile != INVALID_HANDLE_VALUE) { result = _testFileTypeByHandle(hfile, testedType, TRUE); @@ -7141,7 +7143,7 @@ os_utime_impl(PyObject *module, path_t *path, PyObject *times, PyObject *ns, if (path_and_dir_fd_invalid("utime", path, dir_fd) || dir_fd_and_fd_invalid("utime", dir_fd, path->fd) || - fd_and_follow_symlinks_invalid("utime", path->fd, follow_symlinks)) + fd_and_follow_symlinks_invalid("utime", path->is_fd, follow_symlinks)) return NULL; #if !defined(HAVE_UTIMENSAT) @@ -7200,7 +7202,7 @@ os_utime_impl(PyObject *module, path_t *path, PyObject *times, PyObject *ns, #endif #if defined(HAVE_FUTIMES) || defined(HAVE_FUTIMENS) - if (path->fd != -1) + if (path->is_fd) result = utime_fd(&utime, path->fd); else #endif @@ -7569,7 +7571,7 @@ os_execve_impl(PyObject *module, path_t *path, PyObject *argv, PyObject *env) _Py_BEGIN_SUPPRESS_IPH #ifdef HAVE_FEXECVE - if (path->fd > -1) + if (path->is_fd) fexecve(path->fd, argvlist, envlist); else #endif @@ -13355,7 +13357,7 @@ os_truncate_impl(PyObject *module, path_t *path, Py_off_t length) int fd; #endif - if (path->fd != -1) + if (path->is_fd) return os_ftruncate_impl(module, path->fd, length); if (PySys_Audit("os.truncate", "On", path->object, length) < 0) { @@ -14052,7 +14054,7 @@ os_statvfs_impl(PyObject *module, path_t *path) struct statfs st; Py_BEGIN_ALLOW_THREADS - if (path->fd != -1) { + if (path->is_fd) { result = fstatfs(path->fd, &st); } else @@ -14070,7 +14072,7 @@ os_statvfs_impl(PyObject *module, path_t *path) Py_BEGIN_ALLOW_THREADS #ifdef HAVE_FSTATVFS - if (path->fd != -1) { + if (path->is_fd) { result = fstatvfs(path->fd, &st); } else @@ -15410,7 +15412,7 @@ os_getxattr_impl(PyObject *module, path_t *path, path_t *attribute, int follow_symlinks) /*[clinic end generated code: output=5f2f44200a43cff2 input=025789491708f7eb]*/ { - if (fd_and_follow_symlinks_invalid("getxattr", path->fd, follow_symlinks)) + if (fd_and_follow_symlinks_invalid("getxattr", path->is_fd, follow_symlinks)) return NULL; if (PySys_Audit("os.getxattr", "OO", path->object, attribute->object) < 0) { @@ -15432,7 +15434,7 @@ os_getxattr_impl(PyObject *module, path_t *path, path_t *attribute, void *ptr = PyBytesWriter_GetData(writer); Py_BEGIN_ALLOW_THREADS; - if (path->fd >= 0) + if (path->is_fd) result = fgetxattr(path->fd, attribute->narrow, ptr, buffer_size); else if (follow_symlinks) result = getxattr(path->narrow, attribute->narrow, ptr, buffer_size); @@ -15481,7 +15483,7 @@ os_setxattr_impl(PyObject *module, path_t *path, path_t *attribute, { ssize_t result; - if (fd_and_follow_symlinks_invalid("setxattr", path->fd, follow_symlinks)) + if (fd_and_follow_symlinks_invalid("setxattr", path->is_fd, follow_symlinks)) return NULL; if (PySys_Audit("os.setxattr", "OOy#i", path->object, attribute->object, @@ -15490,7 +15492,7 @@ os_setxattr_impl(PyObject *module, path_t *path, path_t *attribute, } Py_BEGIN_ALLOW_THREADS; - if (path->fd > -1) + if (path->is_fd) result = fsetxattr(path->fd, attribute->narrow, value->buf, value->len, flags); else if (follow_symlinks) @@ -15534,7 +15536,7 @@ os_removexattr_impl(PyObject *module, path_t *path, path_t *attribute, { ssize_t result; - if (fd_and_follow_symlinks_invalid("removexattr", path->fd, follow_symlinks)) + if (fd_and_follow_symlinks_invalid("removexattr", path->is_fd, follow_symlinks)) return NULL; if (PySys_Audit("os.removexattr", "OO", path->object, attribute->object) < 0) { @@ -15542,7 +15544,7 @@ os_removexattr_impl(PyObject *module, path_t *path, path_t *attribute, } Py_BEGIN_ALLOW_THREADS; - if (path->fd > -1) + if (path->is_fd) result = fremovexattr(path->fd, attribute->narrow); else if (follow_symlinks) result = removexattr(path->narrow, attribute->narrow); @@ -15584,7 +15586,7 @@ os_listxattr_impl(PyObject *module, path_t *path, int follow_symlinks) const char *name; char *buffer = NULL; - if (fd_and_follow_symlinks_invalid("listxattr", path->fd, follow_symlinks)) + if (fd_and_follow_symlinks_invalid("listxattr", path->is_fd, follow_symlinks)) goto exit; if (PySys_Audit("os.listxattr", "(O)", @@ -15611,7 +15613,7 @@ os_listxattr_impl(PyObject *module, path_t *path, int follow_symlinks) } Py_BEGIN_ALLOW_THREADS; - if (path->fd > -1) + if (path->is_fd) length = flistxattr(path->fd, buffer, buffer_size); else if (follow_symlinks) length = listxattr(name, buffer, buffer_size); @@ -16664,7 +16666,7 @@ DirEntry_from_posix_info(PyObject *module, path_t *path, const char *name, entry->stat = NULL; entry->lstat = NULL; - if (path->fd != -1) { + if (path->is_fd) { entry->dir_fd = path->fd; joined_path = NULL; } @@ -16689,7 +16691,7 @@ DirEntry_from_posix_info(PyObject *module, path_t *path, const char *name, if (!entry->name) goto error; - if (path->fd != -1) { + if (path->is_fd) { entry->path = Py_NewRef(entry->name); } else if (!entry->path) @@ -16813,8 +16815,9 @@ ScandirIterator_closedir(ScandirIterator *iterator) iterator->dirp = NULL; Py_BEGIN_ALLOW_THREADS #ifdef HAVE_FDOPENDIR - if (iterator->path.fd != -1) + if (iterator->path.is_fd) { rewinddir(dirp); + } #endif closedir(dirp); Py_END_ALLOW_THREADS @@ -17034,7 +17037,7 @@ os_scandir_impl(PyObject *module, path_t *path) #else /* POSIX */ errno = 0; #ifdef HAVE_FDOPENDIR - if (iterator->path.fd != -1) { + if (iterator->path.is_fd) { if (HAVE_FDOPENDIR_RUNTIME) { /* closedir() closes the FD, so we duplicate it */ fd = _Py_dup(iterator->path.fd); From d9f64104a6b129c2df30662ffc4525a6d4a64c73 Mon Sep 17 00:00:00 2001 From: Victor Stinner Date: Mon, 2 Mar 2026 20:55:24 +0100 Subject: [PATCH 2/3] fix pathconf test --- Lib/test/test_os/test_os.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Lib/test/test_os/test_os.py b/Lib/test/test_os/test_os.py index 19ac2614548b78..ec94effdfc5181 100644 --- a/Lib/test/test_os/test_os.py +++ b/Lib/test/test_os/test_os.py @@ -2785,13 +2785,14 @@ def check_for_ebadf(self, errnos=(errno.EBADF,)): self.assertIn(ctx.exception.errno, errnos) @unittest.skipUnless(hasattr(os, 'pathconf'), 'test needs os.pathconf()') - @unittest.skipUnless(os.pathconf in os.supports_fd, - 'needs fpathconf()') @unittest.skipIf( support.linked_to_musl(), 'musl fpathconf ignores the file descriptor and returns a constant', ) def test_pathconf_negative_fd_uses_fd_semantics(self): + if os.pathconf not in os.supports_fd: + self.skipTest('needs fpathconf()') + with self.check_for_ebadf(): os.pathconf(-1, 1) From a8942627a890689c32575d094a9932c58c3a161e Mon Sep 17 00:00:00 2001 From: Victor Stinner Date: Mon, 2 Mar 2026 21:05:44 +0100 Subject: [PATCH 3/3] Fix tests on Windows --- Lib/test/test_os/test_os.py | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/Lib/test/test_os/test_os.py b/Lib/test/test_os/test_os.py index ec94effdfc5181..7e359f01ade960 100644 --- a/Lib/test/test_os/test_os.py +++ b/Lib/test/test_os/test_os.py @@ -2815,19 +2815,6 @@ def test_negative_fd_ebadf(self, fd): if os.listdir in os.supports_fd: with self.check_for_ebadf(): os.listdir(fd) - if support.MS_WINDOWS: - with self.check_for_ebadf(): - os._path_exists(fd) - with self.check_for_ebadf(): - os._path_lexists(fd) - with self.check_for_ebadf(): - os._path_isdir(fd) - with self.check_for_ebadf(): - os._path_isfile(fd) - with self.check_for_ebadf(): - os._path_islink(fd) - with self.check_for_ebadf(): - os._path_isjunction(fd) if os.utime in os.supports_fd: with self.check_for_ebadf(): os.utime(fd, (0, 0)) @@ -2854,6 +2841,15 @@ def test_negative_fd_ebadf(self, fd): with self.check_for_ebadf(): os.scandir(fd) + if support.MS_WINDOWS: + import nt + self.assertFalse(nt._path_exists(fd)) + self.assertFalse(nt._path_lexists(fd)) + self.assertFalse(nt._path_isdir(fd)) + self.assertFalse(nt._path_isfile(fd)) + self.assertFalse(nt._path_islink(fd)) + self.assertFalse(nt._path_isjunction(fd)) + @unittest.skipUnless(hasattr(os, 'ftruncate'), 'test needs os.ftruncate()') def test_ftruncate(self): self.check(os.truncate, 0)