diff --git a/Doc/library/os.rst b/Doc/library/os.rst index 9eba3bafae0cb6..58e772c0590361 100644 --- a/Doc/library/os.rst +++ b/Doc/library/os.rst @@ -2807,6 +2807,16 @@ features: This function can also support :ref:`paths relative to directory descriptors `. + On Linux, Android and MacOS, *path* can also be a file descriptor referring + to a symbolic link. In that case, *dir_fd* must be ``None``, and the return + value will be a string. + (On Linux and Android, such a file descriptor can be obtained through + :func:`os.open` with ``os.RDONLY | os.O_PATH | os.O_NOFOLLOW``. + On MacOS, this is possible by calling :func:`os.open` with + ``os.O_RDONLY | os.O_SYMLINK``.) + On other operating systems, a ``NotImplementedError`` is raised if *path* + is an integer. + When trying to resolve a path that may contain links, use :func:`~os.path.realpath` to properly handle recursion and platform differences. @@ -2829,6 +2839,10 @@ features: substitution path (which typically includes ``\\?\`` prefix) rather than the optional "print name" field that was previously returned. + .. versionchanged:: 3.16 + Accepts file descriptors pointing to symbolic links as *path* on + Linux, Android and MacOS. + .. function:: remove(path, *, dir_fd=None) Remove (delete) the file *path*. If *path* is a directory, an diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index 8362b1ef7e312b..9e800e84c959fb 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -509,6 +509,10 @@ os now raises :exc:`PermissionError` instead of the functions being unavailable. (Contributed by Md Arif in :gh:`152936`.) +* :func:`os.readlink` now accepts a file descriptor referring to a symlink on + Linux, Android and MacOS. + (Contributed by OOTS in :gh:`157899`.) + pydoc ----- diff --git a/Lib/os.py b/Lib/os.py index 87547e369db817..1f2fd04d881282 100644 --- a/Lib/os.py +++ b/Lib/os.py @@ -161,6 +161,9 @@ def _add(str, fn): _add("HAVE_FSTATVFS", "statvfs") if _exists("statx"): _set.add(statx) + _add("HAVE_FREADLINK", "readlink") + if sys.platform in ["linux", "android"] and "O_PATH" in _globals: + _add("HAVE_READLINKAT", "readlink") supports_fd = _set _set = set() diff --git a/Lib/test/test_os/test_posix.py b/Lib/test/test_os/test_posix.py index f13ad46aac45aa..232b2f68d51a8a 100644 --- a/Lib/test/test_os/test_posix.py +++ b/Lib/test/test_os/test_posix.py @@ -1881,6 +1881,77 @@ def test_readlink_dir_fd(self): self.addCleanup(posix.unlink, fullname) self.assertEqual(posix.readlink(name, dir_fd=dir_fd), 'symlink') + + _support_readlink_with_fd = hasattr(os, 'readlink') and ( + "HAVE_FREADLINK" in posix._have_functions # MacOS + or ( + os.readlink in os.supports_dir_fd + and sys.platform in ["linux", "android"] + ) + ) + + def _open_symlink_as_fd(self, path): + open_flags = os.O_RDONLY + if hasattr(os, "O_SYMLINK"): # MacOS + open_flags |= os.O_SYMLINK + elif hasattr(os, "O_NOFOLLOW") and hasattr(os, "O_PATH"): # Linux + open_flags |= os.O_NOFOLLOW | os.O_PATH + else: + self.fail("lacking open flags for this test") + return os.open(path, open_flags) + + @unittest.skipUnless(_support_readlink_with_fd, + "feature not supported on this platform") + def test_readlink_with_fd(self): + with self.prepare() as (dir_fd, name, fullname): + os.symlink("symlink", fullname) + self.addCleanup(posix.unlink, fullname) + fd = self._open_symlink_as_fd(fullname) + self.addCleanup(os.close, fd) + self.assertEqual(os.readlink(fd), "symlink") + + @unittest.skipUnless(_support_readlink_with_fd, + "feature not supported on this platform") + def test_readlink_with_fd_not_referring_to_symlink_throws(self): + with self.prepare_file() as (dir_fd, name, fullname): + fd = os.open(fullname, os.O_RDONLY) + self.addCleanup(os.close, fd) + # on Linux/Android, readlinkat("", fd, ...) fails with ENOENT, which + # Python translates to a FileNotFoundError, a subclass of OSError. + # On MacOS, freadlink(fd, ...) fails with EINVAL, which gets raised + # as a OSError. + # So catching OSError here covers both cases. + with self.assertRaises(OSError): + os.readlink(fd) + + @unittest.skipUnless(_support_readlink_with_fd, + "feature not supported on this platform") + def test_readlink_with_fd_throws_if_both_fd_and_dir_fd_are_given(self): + with self.prepare() as (dir_fd, name, fullname): + os.symlink("symlink", fullname) + self.addCleanup(posix.unlink, fullname) + fd = self._open_symlink_as_fd(fullname) + self.addCleanup(os.close, fd) + with self.assertRaises(ValueError): + os.readlink(fd, dir_fd=dir_fd) + + @unittest.skipIf(_support_readlink_with_fd, + "feature is supported on this platform") + def test_readlink_with_fd_throws_not_implemented_error(self): + # on unsupported platforms, we may not even be able to get a + # file descriptor for a symlink, so use a fd for an ordinary file + os_helper.create_empty_file(os_helper.TESTFN) + self.addCleanup(os_helper.unlink, os_helper.TESTFN) + fd = os.open(os_helper.TESTFN, os.O_RDONLY) + self.addCleanup(os.close, fd) + with self.assertRaises(NotImplementedError): + os.readlink(fd) + + @unittest.skipUnless(hasattr(os, "supports_fd") and hasattr(os, "readlink"), + "feature not supported on this platform") + def test_readlink_is_in_supports_fd_on_supported_platforms(self): + self.assertEqual(os.readlink in os.supports_fd, self._support_readlink_with_fd) + @unittest.skipUnless(os.rename in os.supports_dir_fd, "test needs dir_fd support in os.rename()") def test_rename_dir_fd(self): with self.prepare_file() as (dir_fd, name, fullname), \ @@ -2620,6 +2691,17 @@ def test_readlink(self): with self.assertRaisesRegex(NotImplementedError, "dir_fd unavailable"): os.readlink("path", dir_fd=0) + def test_freadlink(self): + self._verify_available("HAVE_FREADLINK") + if self.mac_ver >= (13, 0): + self.assertIn("HAVE_FREADLINK", posix._have_functions) + + else: + self.assertNotIn("HAVE_FREADLINK", posix._have_functions) + + with self.assertRaisesRegex(NotImplementedError, "readlink cannot read file descriptors on this platform"): + os.readlink(0) + def test_symlink(self): self._verify_available("HAVE_SYMLINKAT") if self.mac_ver >= (10, 10): diff --git a/Misc/NEWS.d/next/Library/2026-09-21-14-41-07.gh-issue-157899.s6vBQC.rst b/Misc/NEWS.d/next/Library/2026-09-21-14-41-07.gh-issue-157899.s6vBQC.rst new file mode 100644 index 00000000000000..55384b21999615 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-09-21-14-41-07.gh-issue-157899.s6vBQC.rst @@ -0,0 +1,2 @@ +:func:`os.readlink` now accepts a file descriptor referring to a +symlink on Linux, Android and MacOS. diff --git a/Modules/clinic/posixmodule.c.h b/Modules/clinic/posixmodule.c.h index 7c8171c04c0727..c5f4af7141394d 100644 --- a/Modules/clinic/posixmodule.c.h +++ b/Modules/clinic/posixmodule.c.h @@ -6656,7 +6656,19 @@ PyDoc_STRVAR(os_readlink__doc__, "that directory.\n" "\n" "dir_fd may not be implemented on your platform. If it is unavailable,\n" -"using it will raise a NotImplementedError."); +"using it will raise a NotImplementedError.\n" +"\n" +"On Linux, Android and MacOS, path may be a file descriptor referring to\n" +"a symlink. If it is, dir_fd must be None, and the return value will be a\n" +"string object. (File descriptors for symlinks can be obtained with\n" +"\n" +" os.open(..., os.O_RDONLY | os.O_PATH | os.O_NOFOLLOW)\n" +"\n" +"on Linux and Android, and\n" +"\n" +" os.open(..., os.O_RDONLY | os.O_SYMLINK)\n" +"\n" +"on MacOS.)"); #define OS_READLINK_METHODDEF \ {"readlink", _PyCFunction_CAST(os_readlink), METH_FASTCALL|METH_KEYWORDS, os_readlink__doc__}, @@ -6697,7 +6709,7 @@ os_readlink(PyObject *module, PyObject *const *args, Py_ssize_t nargs, PyObject #undef KWTUPLE PyObject *argsbuf[2]; Py_ssize_t noptargs = nargs + (kwnames ? PyTuple_GET_SIZE(kwnames) : 0) - 1; - path_t path = PATH_T_INITIALIZE_P("readlink", "path", 0, 0, 0, 0); + path_t path = PATH_T_INITIALIZE_P("readlink", "path", 0, 0, 0, 1); int dir_fd = DEFAULT_DIR_FD; args = _PyArg_UnpackKeywords(args, nargs, NULL, kwnames, &_parser, @@ -13747,4 +13759,4 @@ os__emscripten_log(PyObject *module, PyObject *const *args, Py_ssize_t nargs, Py #ifndef OS__EMSCRIPTEN_LOG_METHODDEF #define OS__EMSCRIPTEN_LOG_METHODDEF #endif /* !defined(OS__EMSCRIPTEN_LOG_METHODDEF) */ -/*[clinic end generated code: output=d4e858cbdf280235 input=a9049054013a1b77]*/ +/*[clinic end generated code: output=f98b9987509b0dfa input=a9049054013a1b77]*/ diff --git a/Modules/posixmodule.c b/Modules/posixmodule.c index ead2371e341441..75eac70bd1592c 100644 --- a/Modules/posixmodule.c +++ b/Modules/posixmodule.c @@ -505,6 +505,7 @@ static const unsigned int _Py_STATX_KNOWN = (STATX_BASIC_STATS | STATX_BTIME # define HAVE_UNLINKAT_RUNTIME __builtin_available(macOS 10.10, iOS 8.0, *) # define HAVE_OPENAT_RUNTIME __builtin_available(macOS 10.10, iOS 8.0, *) # define HAVE_READLINKAT_RUNTIME __builtin_available(macOS 10.10, iOS 8.0, *) +# define HAVE_FREADLINK_RUNTIME __builtin_available(macOS 13.0, *) # define HAVE_SYMLINKAT_RUNTIME __builtin_available(macOS 10.10, iOS 8.0, *) # define HAVE_FUTIMENS_RUNTIME __builtin_available(macOS 10.13, iOS 11.0, tvOS 11.0, watchOS 4.0, *) # define HAVE_UTIMENSAT_RUNTIME __builtin_available(macOS 10.13, iOS 11.0, tvOS 11.0, watchOS 4.0, *) @@ -571,6 +572,10 @@ static const unsigned int _Py_STATX_KNOWN = (STATX_BASIC_STATS | STATX_BTIME # define HAVE_READLINKAT_RUNTIME (readlinkat != NULL) # endif +# ifdef _Py_HAVE_FREADLINK +# define HAVE_FREADLINK_RUNTIME (freadlink != NULL) +# endif + # ifdef HAVE_SYMLINKAT # define HAVE_SYMLINKAT_RUNTIME (symlinkat != NULL) # endif @@ -10986,11 +10991,19 @@ os_unshare_impl(PyObject *module, int flags) #endif + #if defined(HAVE_READLINK) || defined(MS_WINDOWS) + +#if (defined(__linux__) || defined(__ANDROID__)) && defined(O_PATH) +// readlinkat(fd, "", ...) reads the symlink that fd refers to. +// supported since Linux 2.6.39 (same version that O_PATH was introduced). +#define _Py_READLINKAT_SUPPORTS_EMPTY_PATH +#endif + /*[clinic input] os.readlink - path: path_t + path: path_t(allow_fd=True) * dir_fd: dir_fd(requires='readlinkat') = None @@ -11002,45 +11015,90 @@ that directory. dir_fd may not be implemented on your platform. If it is unavailable, using it will raise a NotImplementedError. + +On Linux, Android and MacOS, path may be a file descriptor referring to +a symlink. If it is, dir_fd must be None, and the return value will be a +string object. (File descriptors for symlinks can be obtained with + + os.open(..., os.O_RDONLY | os.O_PATH | os.O_NOFOLLOW) + +on Linux and Android, and + + os.open(..., os.O_RDONLY | os.O_SYMLINK) + +on MacOS.) [clinic start generated code]*/ static PyObject * os_readlink_impl(PyObject *module, path_t *path, int dir_fd) -/*[clinic end generated code: output=d21b732a2e814030 input=03d10130870dbca8]*/ +/*[clinic end generated code: output=d21b732a2e814030 input=eda43153b2f38ee6]*/ { #if defined(HAVE_READLINK) char buffer[MAXPATHLEN+1]; ssize_t length; -#ifdef HAVE_READLINKAT - int readlinkat_unavailable = 0; -#endif - Py_BEGIN_ALLOW_THREADS + if (path_and_dir_fd_invalid("readlink", path, dir_fd)) { + return NULL; + } + + if (path->is_fd) { +#if defined(_Py_HAVE_FREADLINK) + if (HAVE_FREADLINK_RUNTIME) { + Py_BEGIN_ALLOW_THREADS + length = freadlink(path->fd, buffer, MAXPATHLEN); + Py_END_ALLOW_THREADS + } else { + PyErr_SetString(PyExc_NotImplementedError, + "readlink cannot read file descriptors on this platform, " + "freadlink() is unavailable"); + return NULL; + } +#elif defined(HAVE_READLINKAT) && defined(_Py_READLINKAT_SUPPORTS_EMPTY_PATH) + // linux/android: + // readlinkat(fd, "", ...) reads the link that fd refers to. + if (HAVE_READLINKAT_RUNTIME) { + Py_BEGIN_ALLOW_THREADS + length = readlinkat(path->fd, "", buffer, MAXPATHLEN); + Py_END_ALLOW_THREADS + } else { + // this should be unreachable: + // HAVE_READLINKAT_RUNTIME is always 1 on Linux/Android. + // Leaving it here as a safeguard. + PyErr_SetString(PyExc_NotImplementedError, + "readlink cannot read file descriptors on this platform, " + "readlinkat() is unavailable"); + return NULL; + } +#else + PyErr_SetString(PyExc_NotImplementedError, + "readlink cannot read file descriptors on this platform"); + return NULL; +#endif + } else #ifdef HAVE_READLINKAT if (dir_fd != DEFAULT_DIR_FD) { if (HAVE_READLINKAT_RUNTIME) { + Py_BEGIN_ALLOW_THREADS length = readlinkat(dir_fd, path->narrow, buffer, MAXPATHLEN); + Py_END_ALLOW_THREADS } else { - readlinkat_unavailable = 1; + argument_unavailable_error(NULL, "dir_fd"); + return NULL; } } else #endif + { + Py_BEGIN_ALLOW_THREADS length = readlink(path->narrow, buffer, MAXPATHLEN); - Py_END_ALLOW_THREADS - -#ifdef HAVE_READLINKAT - if (readlinkat_unavailable) { - argument_unavailable_error(NULL, "dir_fd"); - return NULL; + Py_END_ALLOW_THREADS } -#endif if (length < 0) { return path_error(path); } buffer[length] = '\0'; - if (PyUnicode_Check(path->object)) + if (path->is_fd || PyUnicode_Check(path->object)) return PyUnicode_DecodeFSDefaultAndSize(buffer, length); else return PyBytes_FromStringAndSize(buffer, length); @@ -11052,6 +11110,12 @@ os_readlink_impl(PyObject *module, path_t *path, int dir_fd) _Py_REPARSE_DATA_BUFFER *rdb = (_Py_REPARSE_DATA_BUFFER *)target_buffer; PyObject *result = NULL; + if (path->is_fd) { + PyErr_SetString(PyExc_NotImplementedError, + "readlink cannot read file descriptors on this platform"); + return NULL; + } + /* First get a handle to the reparse point */ Py_BEGIN_ALLOW_THREADS reparse_point_handle = CreateFileW( @@ -18881,6 +18945,10 @@ PROBE(probe_openat, HAVE_OPENAT_RUNTIME) PROBE(probe_readlinkat, HAVE_READLINKAT_RUNTIME) #endif +#ifdef _Py_HAVE_FREADLINK +PROBE(probe_freadlink, HAVE_FREADLINK_RUNTIME) +#endif + #ifdef HAVE_SYMLINKAT PROBE(probe_symlinkat, HAVE_SYMLINKAT_RUNTIME) #endif @@ -18948,6 +19016,10 @@ static const struct have_function { { "HAVE_FPATHCONF", NULL }, #endif +#ifdef _Py_HAVE_FREADLINK + { "HAVE_FREADLINK", probe_freadlink }, +#endif + #ifdef HAVE_FSTATAT { "HAVE_FSTATAT", probe_fstatat }, #endif diff --git a/configure b/configure index e5d3f0091058ac..438deb3593972b 100755 --- a/configure +++ b/configure @@ -21680,6 +21680,48 @@ then : fi + + + { printf "%s\n" "$as_me:${as_lineno-$LINENO}: checking for freadlink" >&5 +printf %s "checking for freadlink... " >&6; } +if test ${ac_cv_func_freadlink+y} +then : + printf %s "(cached) " >&6 +else case e in #( + e) cat confdefs.h - <<_ACEOF >conftest.$ac_ext +/* end confdefs.h. */ +#include +int +main (void) +{ +void *x=freadlink + ; + return 0; +} +_ACEOF +if ac_fn_c_try_compile "$LINENO" +then : + ac_cv_func_freadlink=yes +else case e in #( + e) ac_cv_func_freadlink=no ;; +esac +fi +rm -f core conftest.err conftest.$ac_objext conftest.beam conftest.$ac_ext + ;; +esac +fi +{ printf "%s\n" "$as_me:${as_lineno-$LINENO}: result: $ac_cv_func_freadlink" >&5 +printf "%s\n" "$ac_cv_func_freadlink" >&6; } + if test "x$ac_cv_func_freadlink" = xyes +then : + +printf "%s\n" "#define _Py_HAVE_FREADLINK 1" >>confdefs.h + +fi + + + + fi ac_fn_check_decl "$LINENO" "dirfd" "ac_cv_have_decl_dirfd" "#include diff --git a/configure.ac b/configure.ac index 82a623fedb8192..96b93d7c2443dd 100644 --- a/configure.ac +++ b/configure.ac @@ -5568,6 +5568,7 @@ fi # raise an error if used at runtime. Force these symbols off. if test "$ac_sys_system" != "iOS" ; then AC_CHECK_FUNCS([dup3 getentropy getgroups pipe2 system]) + PY_CHECK_FUNC_PRIVATE([freadlink], [@%:@include ]) fi AC_CHECK_DECL([dirfd], diff --git a/pyconfig.h.in b/pyconfig.h.in index d1de60ad757f4d..d5852dc1e07eaf 100644 --- a/pyconfig.h.in +++ b/pyconfig.h.in @@ -2201,6 +2201,9 @@ /* Defined if _Float16 C type is supported */ #undef _Py_HAVE_FLOAT16 +/* Define if you have the 'freadlink' function. */ +#undef _Py_HAVE_FREADLINK + /* Define if you have a working iconv() function. */ #undef _Py_HAVE_ICONV