diff --git a/Doc/library/ctypes.rst b/Doc/library/ctypes.rst index 615138302e1379..cb02fd33a6e741 100644 --- a/Doc/library/ctypes.rst +++ b/Doc/library/ctypes.rst @@ -1406,6 +1406,28 @@ the shared library name at development time, and hardcode that into the wrapper module instead of using :func:`~ctypes.util.find_library` to locate the library at runtime. +.. _ctypes-listing-loaded-shared-libraries: + +Listing loaded shared libraries +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +When writing code that relies on code loaded from shared libraries, it can be +useful to know which shared libraries have already been loaded into the current +process. + +The :mod:`!ctypes.util` module provides the :func:`~ctypes.util.dllist` function, +which calls the different APIs provided by the various platforms to help determine +which shared libraries have already been loaded into the current process. + +The exact output of this function will be system dependent. On most platforms, +the first entry of this list represents the current process itself, which may +be an empty string. +For example, on glibc-based Linux, the return may look like:: + + >>> from ctypes.util import dllist + >>> dllist() + ['', 'linux-vdso.so.1', '/lib/x86_64-linux-gnu/libm.so.6', '/lib/x86_64-linux-gnu/libc.so.6', ... ] + .. _ctypes-loading-shared-libraries: Loading shared libraries @@ -2083,6 +2105,20 @@ Utility functions .. availability:: Windows +.. function:: dllist() + :module: ctypes.util + + Try to provide a list of paths of the shared libraries loaded into the current + process. These paths are not normalized or processed in any way. The function + can raise :exc:`OSError` if the underlying platform APIs fail. + The exact functionality is system dependent. + + On most platforms, the first element of the list represents the current + executable file. It may be an empty string. + + .. availability:: Windows, macOS, iOS, glibc, BSD libc, musl + .. versionadded:: next + .. function:: FormatError([code]) Returns a textual description of the error code *code*. If no error code is diff --git a/Doc/whatsnew/3.14.rst b/Doc/whatsnew/3.14.rst index 59c432d30a342b..340e29d32a0933 100644 --- a/Doc/whatsnew/3.14.rst +++ b/Doc/whatsnew/3.14.rst @@ -357,6 +357,9 @@ ctypes complex C types. (Contributed by Sergey B Kirpichev in :gh:`61103`). +* Add :func:`ctypes.util.dllist` for listing the shared libraries + loaded by the current process. + (Contributed by Brian Ward in :gh:`119349`.) datetime -------- diff --git a/Lib/ctypes/util.py b/Lib/ctypes/util.py index 117bf06cb01013..99504911a3dbe0 100644 --- a/Lib/ctypes/util.py +++ b/Lib/ctypes/util.py @@ -67,6 +67,65 @@ def find_library(name): return fname return None + # Listing loaded DLLs on Windows relies on the following APIs: + # https://learn.microsoft.com/windows/win32/api/psapi/nf-psapi-enumprocessmodules + # https://learn.microsoft.com/windows/win32/api/libloaderapi/nf-libloaderapi-getmodulefilenamew + import ctypes + from ctypes import wintypes + + _kernel32 = ctypes.WinDLL('kernel32', use_last_error=True) + _get_current_process = _kernel32["GetCurrentProcess"] + _get_current_process.restype = wintypes.HANDLE + + _k32_get_module_file_name = _kernel32["GetModuleFileNameW"] + _k32_get_module_file_name.restype = wintypes.DWORD + _k32_get_module_file_name.argtypes = ( + wintypes.HMODULE, + wintypes.LPWSTR, + wintypes.DWORD, + ) + + _psapi = ctypes.WinDLL('psapi', use_last_error=True) + _enum_process_modules = _psapi["EnumProcessModules"] + _enum_process_modules.restype = wintypes.BOOL + _enum_process_modules.argtypes = ( + wintypes.HANDLE, + ctypes.POINTER(wintypes.HMODULE), + wintypes.DWORD, + wintypes.LPDWORD, + ) + + def _get_module_filename(module: wintypes.HMODULE): + name = (wintypes.WCHAR * 32767)() # UNICODE_STRING_MAX_CHARS + if _k32_get_module_file_name(module, name, len(name)): + return name.value + return None + + + def _get_module_handles(): + process = _get_current_process() + space_needed = wintypes.DWORD() + n = 1024 + while True: + modules = (wintypes.HMODULE * n)() + if not _enum_process_modules(process, + modules, + ctypes.sizeof(modules), + ctypes.byref(space_needed)): + err = ctypes.get_last_error() + msg = ctypes.FormatError(err).strip() + raise ctypes.WinError(err, f"EnumProcessModules failed: {msg}") + n = space_needed.value // ctypes.sizeof(wintypes.HMODULE) + if n <= len(modules): + return modules[:n] + + def dllist(): + """Return a list of loaded shared libraries in the current process.""" + modules = _get_module_handles() + libraries = [name for h in modules + if (name := _get_module_filename(h)) is not None] + return libraries + elif os.name == "posix" and sys.platform in {"darwin", "ios", "tvos", "watchos"}: from ctypes.macholib.dyld import dyld_find as _dyld_find def find_library(name): @@ -80,6 +139,22 @@ def find_library(name): continue return None + # Listing loaded libraries on Apple systems relies on the following API: + # https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man3/dyld.3.html + import ctypes + + _libc = ctypes.CDLL(find_library("c")) + _dyld_get_image_name = _libc["_dyld_get_image_name"] + _dyld_get_image_name.restype = ctypes.c_char_p + + def dllist(): + """Return a list of loaded shared libraries in the current process.""" + num_images = _libc._dyld_image_count() + libraries = [os.fsdecode(name) for i in range(num_images) + if (name := _dyld_get_image_name(i)) is not None] + + return libraries + elif sys.platform.startswith("aix"): # AIX has two styles of storing shared libraries # GNU auto_tools refer to these as svr4 and aix @@ -341,6 +416,55 @@ def find_library(name): return _findSoname_ldconfig(name) or \ _get_soname(_findLib_gcc(name)) or _get_soname(_findLib_ld(name)) + +# Listing loaded libraries on other systems will try to use +# functions common to Linux and a few other Unix-like systems. +# See the following for several platforms' documentation of the same API: +# https://man7.org/linux/man-pages/man3/dl_iterate_phdr.3.html +# https://man.freebsd.org/cgi/man.cgi?query=dl_iterate_phdr +# https://man.openbsd.org/dl_iterate_phdr +# https://docs.oracle.com/cd/E88353_01/html/E37843/dl-iterate-phdr-3c.html +if (os.name == "posix" and + sys.platform not in {"darwin", "ios", "tvos", "watchos"}): + import ctypes + if hasattr((_libc := ctypes.CDLL(None)), "dl_iterate_phdr"): + + class _dl_phdr_info(ctypes.Structure): + _fields_ = [ + ("dlpi_addr", ctypes.c_void_p), + ("dlpi_name", ctypes.c_char_p), + ("dlpi_phdr", ctypes.c_void_p), + ("dlpi_phnum", ctypes.c_ushort), + ] + + _dl_phdr_callback = ctypes.CFUNCTYPE( + ctypes.c_int, + ctypes.POINTER(_dl_phdr_info), + ctypes.c_size_t, + ctypes.POINTER(ctypes.py_object), + ) + + @_dl_phdr_callback + def _info_callback(info, _size, data): + libraries = data.contents.value + name = os.fsdecode(info.contents.dlpi_name) + libraries.append(name) + return 0 + + _dl_iterate_phdr = _libc["dl_iterate_phdr"] + _dl_iterate_phdr.argtypes = [ + _dl_phdr_callback, + ctypes.POINTER(ctypes.py_object), + ] + _dl_iterate_phdr.restype = ctypes.c_int + + def dllist(): + """Return a list of loaded shared libraries in the current process.""" + libraries = [] + _dl_iterate_phdr(_info_callback, + ctypes.byref(ctypes.py_object(libraries))) + return libraries + ################################################################ # test code @@ -384,5 +508,12 @@ def test(): print(cdll.LoadLibrary("libcrypt.so")) print(find_library("crypt")) + try: + dllist + except NameError: + print('dllist() not available') + else: + print(dllist()) + if __name__ == "__main__": test() diff --git a/Lib/test/test_ctypes/test_dllist.py b/Lib/test/test_ctypes/test_dllist.py new file mode 100644 index 00000000000000..15603dc3d77972 --- /dev/null +++ b/Lib/test/test_ctypes/test_dllist.py @@ -0,0 +1,59 @@ +import os +import sys +import unittest +from ctypes import CDLL +import ctypes.util +from test.support import import_helper + + +WINDOWS = os.name == "nt" +APPLE = sys.platform in {"darwin", "ios", "tvos", "watchos"} + +if WINDOWS: + KNOWN_LIBRARIES = ["KERNEL32.DLL"] +elif APPLE: + KNOWN_LIBRARIES = ["libSystem.B.dylib"] +else: + # trickier than it seems, because libc may not be present + # on musl systems, and sometimes goes by different names. + # However, ctypes itself loads libffi + KNOWN_LIBRARIES = ["libc.so", "libffi.so"] + + +@unittest.skipUnless( + hasattr(ctypes.util, "dllist"), + "ctypes.util.dllist is not available on this platform", +) +class ListSharedLibraries(unittest.TestCase): + + def test_lists_system(self): + dlls = ctypes.util.dllist() + + self.assertGreater(len(dlls), 0, f"loaded={dlls}") + self.assertTrue( + any(lib in dll for dll in dlls for lib in KNOWN_LIBRARIES), f"loaded={dlls}" + ) + + def test_lists_updates(self): + dlls = ctypes.util.dllist() + + # this test relies on being able to import a library which is + # not already loaded. + # If it is (e.g. by a previous test in the same process), we skip + if any("_ctypes_test" in dll for dll in dlls): + self.skipTest("Test library is already loaded") + + _ctypes_test = import_helper.import_module("_ctypes_test") + test_module = CDLL(_ctypes_test.__file__) + dlls2 = ctypes.util.dllist() + self.assertIsNotNone(dlls2) + + dlls1 = set(dlls) + dlls2 = set(dlls2) + + self.assertGreater(dlls2, dlls1, f"newly loaded libraries: {dlls2 - dlls1}") + self.assertTrue(any("_ctypes_test" in dll for dll in dlls2), f"loaded={dlls2}") + + +if __name__ == "__main__": + unittest.main() diff --git a/Misc/ACKS b/Misc/ACKS index 47c8d2b40aafb7..af448db615d05f 100644 --- a/Misc/ACKS +++ b/Misc/ACKS @@ -1992,6 +1992,7 @@ Edward C Wang Jiahua Wang Ke Wang Liang-Bo Wang +Brian Ward Greg Ward Tom Wardill Zachary Ware diff --git a/Misc/NEWS.d/next/Library/2024-08-12-11-58-15.gh-issue-119349.-xTnHl.rst b/Misc/NEWS.d/next/Library/2024-08-12-11-58-15.gh-issue-119349.-xTnHl.rst new file mode 100644 index 00000000000000..5dd8264a608dfa --- /dev/null +++ b/Misc/NEWS.d/next/Library/2024-08-12-11-58-15.gh-issue-119349.-xTnHl.rst @@ -0,0 +1,2 @@ +Add the :func:`ctypes.util.dllist` function to list the loaded shared +libraries for the current process.