From 17f29b3923a5628d891d1029891439a69eb862db Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Fri, 11 Sep 2026 08:39:11 +0200 Subject: [PATCH] ffi: load libraries from a mounted VFS The operating system's dynamic loader cannot open a library that lives in a mounted virtual file system: the reserved mount path has no real inode. Native addons already handle this in require(): the loader hands their bytes to process.dlopen(), which loads them from a private, self-cleaning image - an anonymous in-memory memfd on Linux. Make ffi.dlopen() and new DynamicLibrary() do the same transparently. Mirroring the fs handler integration, the VFS hook installer sets a library reader into node:ffi while at least one VFS is mounted and clears it when the last one unmounts; DynamicLibrary consults it before every load, so the dependency points from the VFS into ffi and ffi never loads any VFS code. The reader hands the library's bytes to the native constructor, which loads them from the same kind of image, released right after the load, while library.path keeps reporting the virtual path. Libraries on the real file system are unaffected and load directly, and pay only a null check while no VFS is mounted. The AddonImage materializer moves from an anonymous namespace in node_binding.cc to node_binding.h so that node_ffi.cc can reuse it. On Windows the image is now written and closed before the load, because the loader shares read alone and a retained writable delete-on-close handle failed the load with ERROR_SHARING_VIOLATION; since a mapped image cannot be unlinked there, it is kept with the module it loaded as and both are released at process exit. On POSIX the image still never outlives the constructor call, so nothing is left for dlclose() to clean up. Also fix the VFS dlopen hook forwarding a missing flags argument as undefined, which process.dlopen() coerces to 0 - not a valid dlopen(2) mode - so loading any addon from the real file system failed with EINVAL while a VFS was mounted. Co-authored-by: Philipp Dunkel Signed-off-by: Matteo Collina --- doc/api/ffi.md | 25 +++ doc/api/vfs.md | 8 + lib/ffi.js | 34 +++- lib/internal/ffi/vfs.js | 27 +++ lib/internal/vfs/setup.js | 32 ++++ src/node_binding.cc | 177 +++++++++--------- src/node_binding.h | 56 ++++++ src/node_ffi.cc | 38 +++- test/ffi/test-ffi-vfs.js | 94 ++++++++++ .../test-dlopen-binary-image-cleanup.js | 90 +++++++++ test/parallel/test-vfs-addon.js | 8 + 11 files changed, 501 insertions(+), 88 deletions(-) create mode 100644 lib/internal/ffi/vfs.js create mode 100644 test/ffi/test-ffi-vfs.js create mode 100644 test/parallel/test-dlopen-binary-image-cleanup.js diff --git a/doc/api/ffi.md b/doc/api/ffi.md index ba7f866cebff..e3ecdf93626e 100644 --- a/doc/api/ffi.md +++ b/doc/api/ffi.md @@ -210,6 +210,11 @@ const path = `libsqlite3.${suffix}`; * `path` {string|null} Path to a dynamic library, or `null` to resolve symbols @@ -221,6 +226,13 @@ Loads a dynamic library and resolves the requested function definitions. On Windows passing `null` is not supported. +A `path` inside a mounted [virtual file system][] is supported: the +operating system's dynamic loader cannot open a virtual path, so the +library's bytes are read from the VFS and loaded from a private, +self-cleaning temporary image instead, while `lib.path` keeps reporting +the virtual path. Libraries on the real file system are unaffected and +load directly. + When `definitions` is omitted, `functions` is returned as an empty object until symbols are resolved explicitly. @@ -302,6 +314,14 @@ Represents a loaded dynamic library. ### `new DynamicLibrary(path)` + + * `path` {string|null} Path to a dynamic library, or `null` to resolve symbols from the current process image. @@ -309,6 +329,9 @@ Loads the dynamic library without resolving any functions eagerly. On Windows passing `null` is not supported. +A `path` inside a mounted [virtual file system][] loads the same way as +with [`ffi.dlopen()`][]. + ```cjs const { DynamicLibrary, suffix } = require('node:ffi'); @@ -798,7 +821,9 @@ and keep callback and pointer lifetimes explicit on the native side. [Permission Model]: permissions.md#permission-model [`--allow-ffi`]: cli.md#--allow-ffi +[`ffi.dlopen()`]: #ffidlopenpath-definitions [`ffi.toBuffer(pointer, length, copy)`]: #ffitobufferpointer-length-copy [`library.functions`]: #libraryfunctions [`using`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/using [type names]: #type-names +[virtual file system]: vfs.md diff --git a/doc/api/vfs.md b/doc/api/vfs.md index 6f67a85ad8a8..40998b9a548c 100644 --- a/doc/api/vfs.md +++ b/doc/api/vfs.md @@ -425,6 +425,12 @@ addon's bytes are read from the VFS and loaded from a private, self-cleaning temporary image instead. Addons on the real file system are unaffected and load directly. +Shared libraries opened through [`ffi.dlopen()`][] (or +[`new ffi.DynamicLibrary()`][]) work the same way: a library path inside a +mounted VFS is detected, its bytes are read from the VFS, and the library is +loaded from a private, self-cleaning image while `library.path` keeps +reporting the virtual path. Libraries on the real file system load directly. + ## Use with Single Executable Applications When running as a [Single Executable Application][] built with @@ -634,9 +640,11 @@ fields use synthetic but stable values: [`VirtualFileSystem`]: #class-virtualfilesystem [`VirtualProvider`]: #class-virtualprovider [`ZipProvider`]: #class-zipprovider +[`ffi.dlopen()`]: ffi.md#ffidlopenpath-definitions [`fs.BigIntStats`]: fs.md#class-fsstats [`fs.Stats`]: fs.md#class-fsstats [`import.meta.resolve()`]: esm.md#importmetaresolvespecifier +[`new ffi.DynamicLibrary()`]: ffi.md#new-dynamiclibrarypath [`node:fs`]: fs.md [`require()`]: modules.md#requireid [`require.resolve()`]: modules.md#requireresolverequest-options diff --git a/lib/ffi.js b/lib/ffi.js index ce8345f155fb..5cd7c4b354ab 100644 --- a/lib/ffi.js +++ b/lib/ffi.js @@ -9,6 +9,7 @@ const { ObjectGetOwnPropertyDescriptor, ObjectKeys, ObjectPrototypeToString, + ReflectConstruct, SafeWeakMap, SafeWeakRef, SymbolDispose, @@ -38,7 +39,7 @@ const { emitExperimentalWarning('FFI'); const { - DynamicLibrary, + DynamicLibrary: NativeDynamicLibrary, getInt8, getUint8, getInt16, @@ -119,6 +120,37 @@ function wrapFFIFunction(rawFn, owner) { return wrapped; } +const { getVfsLibraryReader } = require('internal/ffi/vfs'); + +// A thin constructor in front of the native class so that a library inside +// a mounted virtual file system loads transparently: its bytes are read +// from the VFS and handed to the native constructor, which loads them from +// a private, self-cleaning image - the same way require() handles a native +// addon in a VFS. The reader is installed by the VFS while it is mounted +// (see internal/ffi/vfs), so no VFS code is ever loaded from here. The +// wrapper shares the native prototype, so instances and instanceof behave +// as if the native class were exposed directly. +function DynamicLibrary(path) { + if (new.target === undefined) { + // Let the native constructor produce its usual error. + return FunctionPrototypeCall(NativeDynamicLibrary, this, path); + } + const readVirtualLibrary = getVfsLibraryReader(); + const binary = + readVirtualLibrary === null || typeof path !== 'string' ? + undefined : readVirtualLibrary(path); + return ReflectConstruct(NativeDynamicLibrary, + binary === undefined ? [path] : [path, binary], + new.target); +} +DynamicLibrary.prototype = NativeDynamicLibrary.prototype; +ObjectDefineProperty(DynamicLibrary.prototype, 'constructor', { + __proto__: null, + configurable: true, + value: DynamicLibrary, + writable: true, +}); + const rawGetFunction = DynamicLibrary.prototype.getFunction; const rawGetFunctions = DynamicLibrary.prototype.getFunctions; const rawClose = DynamicLibrary.prototype.close; diff --git a/lib/internal/ffi/vfs.js b/lib/internal/ffi/vfs.js new file mode 100644 index 000000000000..eb6342ec0452 --- /dev/null +++ b/lib/internal/ffi/vfs.js @@ -0,0 +1,27 @@ +'use strict'; + +// Seam between node:ffi and the virtual file system, mirroring the fs +// handler integration in internal/fs/utils: the VFS hook installer sets a +// library reader while at least one VFS is mounted and clears it when the +// last one unmounts, and DynamicLibrary consults it before every load. The +// dependency points from the VFS into ffi: ffi never loads any VFS code, +// and pays only a null check while no VFS is mounted. + +// When reader is null, no VFS is active (zero overhead). Otherwise it is +// (path) => Buffer|undefined: the library's bytes for a path inside a +// mounted VFS, or undefined for a path the dynamic loader should open +// itself. +let vfsLibraryReader = null; + +function setVfsLibraryReader(reader) { + vfsLibraryReader = reader; +} + +function getVfsLibraryReader() { + return vfsLibraryReader; +} + +module.exports = { + getVfsLibraryReader, + setVfsLibraryReader, +}; diff --git a/lib/internal/vfs/setup.js b/lib/internal/vfs/setup.js index 3e6f246d794a..712b0d85a8c7 100644 --- a/lib/internal/vfs/setup.js +++ b/lib/internal/vfs/setup.js @@ -968,10 +968,38 @@ function installAddonLoader() { const { dlopenBinary } = internalBinding('process_methods'); return dlopenBinary(module, filename, flags, readFileSync(filename)); } + // Do not forward a missing flags argument as `undefined`: + // process.dlopen() coerces it to 0, which is not a valid dlopen(2) + // mode, instead of applying the default flags. + if (flags === undefined) return originalDlopen(module, filename); return originalDlopen(module, filename, flags); }; } +/** + * Reads the bytes of a file that lives in a mounted VFS. Returns undefined + * for a path outside the reserved VFS root - the caller should open the + * path itself - and throws ENOENT for a path under the root that no + * mounted VFS serves, since no real file can exist there. Installed into + * node:ffi while hooks are installed, so DynamicLibrary can load a + * VFS-resident library from a private image, the same way the module + * loader handles a native addon in a VFS. + * @param {string} pathStr The path of the library + * @returns {Buffer|undefined} The library's bytes, or undefined + */ +function readVirtualBinary(pathStr) { + const normalized = normalizeMountedPath(pathStr); + if (!StringPrototypeStartsWith(normalized, normalizedVfsRootPrefix)) { + return undefined; + } + const layerId = getLayerIdFromPath(normalized); + const vfs = layerId === -1 ? undefined : activeVFSLayers.get(layerId); + if (vfs === undefined || !vfs.shouldHandleNormalized(normalized)) { + throw createENOENT('open', pathStr); + } + return vfs.readFileSync(normalized); +} + /** * Install all VFS hooks: module loader overrides and fs handlers. */ @@ -981,6 +1009,8 @@ function installHooks() { normalizedVfsRootPrefix = getNormalizedVfsRoot() + sep; installModuleLoaderOverrides(); installAddonLoader(); + const { setVfsLibraryReader } = require('internal/ffi/vfs'); + setVfsLibraryReader(readVirtualBinary); vfsHandlerObj = createVfsHandlers(); setVfsHandlers(vfsHandlerObj); hooksInstalled = true; @@ -998,6 +1028,8 @@ function uninstallHooks() { setLoaderOverrides(); setVfsHandlers(null); vfsHandlerObj = undefined; + const { setVfsLibraryReader } = require('internal/ffi/vfs'); + setVfsLibraryReader(null); process.dlopen = originalDlopen; hooksInstalled = false; } diff --git a/src/node_binding.cc b/src/node_binding.cc index 0437f59ca60e..52d388434873 100644 --- a/src/node_binding.cc +++ b/src/node_binding.cc @@ -8,7 +8,9 @@ #include "permission/permission.h" #include "util.h" +#include #include +#include #include #ifdef _WIN32 @@ -17,7 +19,6 @@ #include #include #include -#include #if defined(__linux__) #include #include @@ -474,64 +475,60 @@ int NodeMemfdCreate(const char* name, unsigned int flags) { return static_cast(syscall(SYS_memfd_create, name, flags)); } #endif // __linux__ +#else // _WIN32 + +// Windows refuses to unlink a file that backs a mapped image section: neither +// delete-on-close, nor DeleteFile(), nor a POSIX-semantics disposition can +// remove it while the DLL is loaded. A materialized image therefore has to +// outlive its load, and the only moment it can go is once the module is +// unloaded again. Node keeps addons loaded for the life of the process, so +// that moment is process exit: each image is kept here with the module it was +// loaded as, and released together at exit. +struct RetainedAddonImage { + HMODULE module; + std::wstring path; +}; +Mutex g_retained_addon_images_mutex; +std::vector* g_retained_addon_images = nullptr; + +// Unloads the images this process materialized -- and only those; addons loaded +// from a real path are left alone -- so that each file can finally be deleted. +// This has to happen after everything that might still call into an addon, so +// it is registered during static initialisation below: atexit() runs handlers +// last-registered-first, so registering before main() puts this behind every +// handler that is registered while running. +void ReleaseRetainedAddonImages() { + Mutex::ScopedLock lock(g_retained_addon_images_mutex); + if (g_retained_addon_images == nullptr) return; + for (auto it = g_retained_addon_images->rbegin(); + it != g_retained_addon_images->rend(); + ++it) { + // Deleting first doubles as the test for whether the image is still + // mapped, because that is the only thing that can stop it: an FFI library + // the caller already close()d is gone by now, and unloading it a second + // time through a stale module handle would be wrong. + if (DeleteFileW(it->path.c_str())) continue; + if (it->module != nullptr) FreeLibrary(it->module); + DeleteFileW(it->path.c_str()); + } + g_retained_addon_images->clear(); +} + +// Arms the hook before main() rather than at the first load; see above. +const struct RetainedAddonImageExitHook { + RetainedAddonImageExitHook() { atexit(ReleaseRetainedAddonImages); } +} g_retained_addon_image_exit_hook; + #endif // !_WIN32 -// Materializes native-addon bytes into a form dlopen()/LoadLibrary() can load, -// with the smallest, most private on-disk footprint each platform allows: -// Linux: an anonymous in-memory memfd, loaded via /proc/self/fd/N - -// the bytes never touch the filesystem. -// other POSIX: a 0700 mkdtemp() directory plus an O_EXCL|O_NOFOLLOW file, -// unlink()ed right after the load (the mapping keeps it alive). -// Windows: a temp file opened FILE_FLAG_DELETE_ON_CLOSE; its handle is -// retained for the process lifetime so the file is removed -// automatically once the process (and the loaded DLL) exit. -// Used for an addon that lives somewhere dlopen() cannot open by path, such as -// a virtual file system. -class AddonImage { - public: - AddonImage() = default; - ~AddonImage(); - AddonImage(const AddonImage&) = delete; - AddonImage& operator=(const AddonImage&) = delete; - - // The directory a temporary image would be written to, with a trailing - // separator; empty when it cannot be determined. Names the resource for the - // file-system permission check. - static std::string TempDir(); - - // On success sets path() to a real, loadable path for `data`. - bool Materialize(const char* data, size_t len); - const std::string& path() const { return path_; } - const std::string& errmsg() const { return errmsg_; } - - // Call exactly once, right after DLib::Open(); `opened` says whether the load - // succeeded. Releases the transient resources that are no longer needed (a - // successful load holds its own mapping): on POSIX closes the memfd or - // unlinks the temp file; on Windows retains the delete-on-close handle for - // the process lifetime when opened, or closes it (deleting the file) on - // failure. - void AfterOpen(bool opened); +} // namespace - private: - std::string path_; - std::string errmsg_; - bool consumed_ = false; -#ifdef _WIN32 - HANDLE handle_ = INVALID_HANDLE_VALUE; -#else - bool MaterializeTempFile(const char* data, size_t len); - int fd_ = -1; - std::string temp_dir_; // non-empty only for the temp-file (non-memfd) path -#endif -}; +// AddonImage is declared in node_binding.h so that the other loader of +// dynamically shared objects, node_ffi.cc, can reuse it; see the header for +// the platform-by-platform description. #ifdef _WIN32 -// Delete-on-close handles kept alive until process exit so their temp files -// outlive the loaded DLLs and are removed once the process ends. -Mutex g_retained_addon_handles_mutex; -std::vector* g_retained_addon_handles = nullptr; - // static std::string AddonImage::TempDir() { wchar_t dir[MAX_PATH + 1]; @@ -558,18 +555,22 @@ bool AddonImage::Materialize(const char* data, size_t len) { errmsg_ = "could not create a temporary file name"; return false; } - // Reopen the just-created file delete-on-close, sharing delete so the loader - // can map it while it is delete-pending; the file is removed when this handle - // and the loader's section are both released (i.e. at process exit). - handle_ = CreateFileW(file, - GENERIC_READ | GENERIC_WRITE, - FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, - nullptr, - CREATE_ALWAYS, - FILE_ATTRIBUTE_TEMPORARY | FILE_FLAG_DELETE_ON_CLOSE, - nullptr); - if (handle_ == INVALID_HANDLE_VALUE) { + // Write the image and close it again: nothing may still hold the file open + // when the loader gets to it. Sharing is checked in both directions, and the + // loader opens a DLL for read and execute while sharing read alone, so any + // handle of ours holding write access fails the load with + // ERROR_SHARING_VIOLATION however permissive this side's share mode is. + HANDLE writer = + CreateFileW(file, + GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + nullptr, + CREATE_ALWAYS, + FILE_ATTRIBUTE_TEMPORARY, + nullptr); + if (writer == INVALID_HANDLE_VALUE) { errmsg_ = "could not create a temporary file for the native addon"; + DeleteFileW(file); return false; } size_t off = 0; @@ -577,48 +578,52 @@ bool AddonImage::Materialize(const char* data, size_t len) { DWORD chunk = len - off > MAXDWORD ? MAXDWORD : static_cast(len - off); DWORD written = 0; - if (!WriteFile(handle_, data + off, chunk, &written, nullptr)) { + if (!WriteFile(writer, data + off, chunk, &written, nullptr)) { errmsg_ = "could not write the native addon to a temporary file"; - CloseHandle(handle_); - handle_ = INVALID_HANDLE_VALUE; + CloseHandle(writer); + DeleteFileW(file); return false; } off += written; } + CloseHandle(writer); + int utf8_len = WideCharToMultiByte(CP_UTF8, 0, file, -1, nullptr, 0, nullptr, nullptr); if (utf8_len <= 0) { errmsg_ = "could not encode the temporary file path"; - CloseHandle(handle_); - handle_ = INVALID_HANDLE_VALUE; + DeleteFileW(file); return false; } path_.resize(utf8_len - 1); WideCharToMultiByte( CP_UTF8, 0, file, -1, path_.data(), utf8_len, nullptr, nullptr); + wpath_ = file; return true; } -void AddonImage::AfterOpen(bool opened) { +void AddonImage::AfterOpen(bool opened, void* module) { consumed_ = true; - if (handle_ == INVALID_HANDLE_VALUE) return; + if (wpath_.empty()) return; if (!opened) { - CloseHandle(handle_); // delete-on-close removes the file - handle_ = INVALID_HANDLE_VALUE; + DeleteFileW(wpath_.c_str()); // nothing mapped it, so it can go now + wpath_.clear(); return; } - Mutex::ScopedLock lock(g_retained_addon_handles_mutex); - if (g_retained_addon_handles == nullptr) { - g_retained_addon_handles = new std::vector(); + // The load mapped it, so it has to stay until that module is unloaded again. + Mutex::ScopedLock lock(g_retained_addon_images_mutex); + if (g_retained_addon_images == nullptr) { + g_retained_addon_images = new std::vector(); } - g_retained_addon_handles->push_back(handle_); - handle_ = INVALID_HANDLE_VALUE; + g_retained_addon_images->push_back( + {static_cast(module), std::move(wpath_)}); + wpath_.clear(); } AddonImage::~AddonImage() { - // Materialized but Open() was never reached (e.g. an exception in between): - // closing the delete-on-close handle removes the file. - if (!consumed_ && handle_ != INVALID_HANDLE_VALUE) CloseHandle(handle_); + // Materialized but the load was never reached (e.g. an exception in + // between): nothing mapped the file, so remove it now. + if (!consumed_ && !wpath_.empty()) DeleteFileW(wpath_.c_str()); } #else // !_WIN32 @@ -700,10 +705,12 @@ bool AddonImage::MaterializeTempFile(const char* data, size_t len) { return true; } -void AddonImage::AfterOpen(bool opened) { +void AddonImage::AfterOpen(bool opened, void* module) { consumed_ = true; - // The right cleanup is the same whether or not the load worked. + // The right cleanup is the same whether or not the load worked, and the + // module never has to be unloaded: the name is already gone by now. (void)opened; + (void)module; // memfd: the load's mapping (or nothing, on failure) owns it from here. if (fd_ != -1) { close(fd_); @@ -730,8 +737,6 @@ AddonImage::~AddonImage() { #endif // _WIN32 -} // namespace - // Shared by process.dlopen() and the internal dlopenBinary(). `allow_binary` // says whether args[3] may carry the addon's bytes; it is false for // process.dlopen(), whose signature stays (module, filename[, flags]). @@ -814,7 +819,7 @@ static void DLOpenImpl(const FunctionCallbackInfo& args, Mutex::ScopedLock lock(dlib_load_mutex); const bool is_opened = dlib->Open(); - image.AfterOpen(is_opened); + image.AfterOpen(is_opened, is_opened ? dlib->handle_ : nullptr); // Objects containing v14 or later modules will have registered themselves // on the pending list. Activate all of them now. At present, only one diff --git a/src/node_binding.h b/src/node_binding.h index c200cc0d0c8a..0f19475ab48e 100644 --- a/src/node_binding.h +++ b/src/node_binding.h @@ -7,6 +7,8 @@ #include #endif +#include + #include "node.h" #include "node_api.h" #include "quic/guard.h" @@ -170,6 +172,60 @@ void GetLinkedBinding(const v8::FunctionCallbackInfo& args); void DLOpen(const v8::FunctionCallbackInfo& args); void DLOpenBinary(const v8::FunctionCallbackInfo& args); +// Materializes the bytes of a dynamically shared object into a form +// dlopen()/LoadLibrary() can load, with the smallest, most private on-disk +// footprint each platform allows: +// Linux: an anonymous in-memory memfd, loaded via /proc/self/fd/N - +// the bytes never touch the filesystem. +// other POSIX: a 0700 mkdtemp() directory plus an O_EXCL|O_NOFOLLOW file, +// unlink()ed right after the load (the mapping keeps it alive). +// Windows: a temp file, written and closed before the load because the +// loader shares read alone. It cannot be unlinked while its +// image is mapped, so it is kept with the module it loaded as +// and both are released at process exit. +// Used for a native addon or an FFI library that lives somewhere the dynamic +// loader cannot open by path, such as a virtual file system. Call exactly one +// of Materialize()+AfterOpen() around the load; a destroyed image that never +// reached AfterOpen() cleans up after itself. +class AddonImage { + public: + AddonImage() = default; + ~AddonImage(); + AddonImage(const AddonImage&) = delete; + AddonImage& operator=(const AddonImage&) = delete; + + // The directory a temporary image would be written to, with a trailing + // separator; empty when it cannot be determined. Names the resource for the + // file-system permission check. + static std::string TempDir(); + + // On success sets path() to a real, loadable path for `data`. + bool Materialize(const char* data, size_t len); + const std::string& path() const { return path_; } + const std::string& errmsg() const { return errmsg_; } + + // Call exactly once, right after the load; `opened` says whether the load + // succeeded and `module` is the module handle it produced. Releases what is + // no longer needed: on POSIX closes the memfd or unlinks the temp file, which + // a successful load keeps alive through its own mapping. Windows cannot + // unlink a mapped image, so there the file is removed at once only when the + // load failed; otherwise it is kept, with `module`, until process exit, where + // the module is unloaded and the file finally deleted. + void AfterOpen(bool opened, void* module); + + private: + std::string path_; + std::string errmsg_; + bool consumed_ = false; +#ifdef _WIN32 + std::wstring wpath_; // the path of the image, to delete it again at exit +#else + bool MaterializeTempFile(const char* data, size_t len); + int fd_ = -1; + std::string temp_dir_; +#endif +}; + } // namespace binding } // namespace node diff --git a/src/node_ffi.cc b/src/node_ffi.cc index 88327f1f1c47..a596fab7c683 100644 --- a/src/node_ffi.cc +++ b/src/node_ffi.cc @@ -10,6 +10,7 @@ #include "ffi/data.h" #include "ffi/fast.h" #include "ffi/types.h" +#include "node_binding.h" #include "node_errors.h" namespace node { @@ -525,9 +526,44 @@ void DynamicLibrary::New(const FunctionCallbackInfo& args) { library_path = lib->path_.c_str(); } + // On the internal path args[1] carries the library's bytes, for a library + // that lives somewhere the dynamic loader cannot open by path (a virtual + // file system). Materialize them into a private, self-cleaning image - the + // same mechanism process.dlopen() uses for such native addons - and load + // that, while still reporting the library's own path in `library.path` and + // any error. + binding::AddonImage image; + if (args.Length() > 1 && !args[1]->IsUndefined()) { + if (!args[1]->IsArrayBufferView()) { + THROW_ERR_INVALID_ARG_TYPE( + env, "Library binary must be a Buffer, TypedArray, or DataView"); + return; + } + // Loading from bytes materializes them into an image in the temporary + // directory, so this needs write access there on top of the FFI + // permission checked above. The check does not depend on whether the + // image actually reaches the file system on this platform (Linux uses an + // anonymous memfd): what a program must be granted should not vary by + // platform. + THROW_IF_INSUFFICIENT_PERMISSIONS( + env, + permission::PermissionScope::kFileSystemWrite, + binding::AddonImage::TempDir()); + ArrayBufferViewContents binary(args[1]); + if (!image.Materialize(binary.data(), binary.length())) { + THROW_ERR_FFI_CALL_FAILED( + env, "dlopen failed: %s: %s", image.errmsg().c_str(), library_path); + return; + } + library_path = image.path().c_str(); + } + CHECK(lib->is_closed()); // Open the library - if (uv_dlopen(library_path, &lib->lib_) != 0) { + const bool opened = uv_dlopen(library_path, &lib->lib_) == 0; + image.AfterOpen(opened, + opened ? static_cast(lib->lib_.handle) : nullptr); + if (!opened) { THROW_ERR_FFI_CALL_FAILED(env, "dlopen failed: %s", uv_dlerror(&lib->lib_)); return; } diff --git a/test/ffi/test-ffi-vfs.js b/test/ffi/test-ffi-vfs.js new file mode 100644 index 000000000000..884bd9f0d5da --- /dev/null +++ b/test/ffi/test-ffi-vfs.js @@ -0,0 +1,94 @@ +// Flags: --experimental-vfs +'use strict'; +const common = require('../common'); +common.skipIfFFIMissing(); +const assert = require('node:assert'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { test } = require('node:test'); +const ffi = require('node:ffi'); +const vfs = require('node:vfs'); +const { fixtureSymbols, libraryPath } = require('./ffi-test-common'); + +// A library inside a mounted VFS loads transparently: the dynamic loader +// cannot open the reserved mount path, so its bytes are read from the VFS +// and loaded from a private, self-cleaning image - the same way require() +// handles a native addon in a VFS. +const libraryName = path.basename(libraryPath); +const myVfs = vfs.create(); +myVfs.writeFileSync(`/${libraryName}`, fs.readFileSync(libraryPath)); +const mountPoint = myVfs.mount(); +const virtualPath = path.join(mountPoint, libraryName); + +test('ffi.dlopen() loads a library from a mounted VFS', () => { + const before = new Set(fs.readdirSync(os.tmpdir())); + const { lib, functions } = ffi.dlopen(virtualPath, { + add_i32: fixtureSymbols.add_i32, + }); + + try { + assert.ok(lib instanceof ffi.DynamicLibrary); + // The library reports its own (virtual) path, not the image's. + assert.strictEqual(lib.path, virtualPath); + assert.strictEqual(functions.add_i32(2, 40), 42); + } finally { + lib.close(); + } + + // On Linux the image is an in-memory memfd that never touches the + // filesystem; on other POSIX it is unlinked right after loading. + // (Windows keeps a delete-on-close file until exit, so skip there.) + if (!common.isWindows) { + const leaked = fs.readdirSync(os.tmpdir()) + .filter((f) => f.startsWith('node-addon') && !before.has(f)); + assert.deepStrictEqual(leaked, [], `image not cleaned up: ${leaked}`); + } +}); + +test('new ffi.DynamicLibrary() loads from a mounted VFS', () => { + const lib = new ffi.DynamicLibrary(virtualPath); + + try { + assert.strictEqual(lib.path, virtualPath); + const addU8 = lib.getFunction('add_u8', fixtureSymbols.add_u8); + assert.strictEqual(addU8(19, 23), 42); + } finally { + lib.close(); + } +}); + +test('a missing library inside the VFS throws ENOENT', () => { + assert.throws(() => { + ffi.dlopen(path.join(mountPoint, 'no-such-library.so')); + }, { code: 'ENOENT' }); +}); + +test('libraries on the real file system still load directly', () => { + const { lib, functions } = ffi.dlopen(libraryPath, { + add_i32: fixtureSymbols.add_i32, + }); + + try { + assert.strictEqual(lib.path, libraryPath); + assert.strictEqual(functions.add_i32(-1, 2), 1); + } finally { + lib.close(); + } +}); + +test('a library loaded from a VFS outlives the mount', () => { + const otherVfs = vfs.create(); + otherVfs.writeFileSync(`/${libraryName}`, fs.readFileSync(libraryPath)); + const otherMount = otherVfs.mount(); + const { lib, functions } = ffi.dlopen(path.join(otherMount, libraryName), { + add_i32: fixtureSymbols.add_i32, + }); + + try { + otherVfs.unmount(); + assert.strictEqual(functions.add_i32(20, 22), 42); + } finally { + lib.close(); + } +}); diff --git a/test/parallel/test-dlopen-binary-image-cleanup.js b/test/parallel/test-dlopen-binary-image-cleanup.js new file mode 100644 index 000000000000..dabaeabaa0cc --- /dev/null +++ b/test/parallel/test-dlopen-binary-image-cleanup.js @@ -0,0 +1,90 @@ +// Flags: --expose-internals +'use strict'; + +// Loading an addon from bytes materializes them into a private image so the +// dynamic loader has a real path to open. That image is transient and must not +// outlive the process that loaded it. How it is held differs by platform, so +// this checks both halves of the contract: +// +// Linux: an anonymous memfd loaded through /proc/self/fd - nothing ever +// reaches the filesystem, and AfterOpen() closes the descriptor +// once the load owns its mapping, so repeated loads must not +// accumulate open descriptors. +// other POSIX: a mkdtemp() directory unlinked and rmdir()ed right after the +// load, so nothing is left even while the process runs. +// Windows: the loader maps the file by path for the DLL's lifetime, so +// the image has to stay put; it is retained with the module it +// loaded as, and both are released at process exit. + +const common = require('../common'); +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); +const tmpdir = require('../common/tmpdir'); + +const addonPath = path.join( + __dirname, '..', 'addons', 'hello-world', 'build', 'Release', 'binding.node'); +if (!fs.existsSync(addonPath)) common.skip('the hello-world addon is not built'); + +tmpdir.refresh(); + +// Where a temp-file image would land: GetTempPathW() reads TMP/TEMP and +// TempDir() reads TMPDIR, so pointing all three at a directory this test owns +// keeps any image the child writes somewhere it can inspect afterwards. Linux +// normally uses a memfd and never writes here at all. +const imageDir = tmpdir.resolve('addon-images'); +fs.mkdirSync(imageDir, { recursive: true }); + +const child = ` + const fs = require('fs'); + const { internalBinding } = require('internal/test/binding'); + const { dlopenBinary } = internalBinding('process_methods'); + const bytes = fs.readFileSync(${JSON.stringify(addonPath)}); + // A path that does not exist on disk, as a VFS-resident addon would be, so + // the load can only come from the bytes and their materialized image. + const virtualPath = ${JSON.stringify(path.join(addonPath, '..', 'nowhere', 'binding.node'))}; + + // On Linux the image is a descriptor rather than a file, so count them: each + // load must hand its fd to the mapping and close it, leaving no growth. + const fdDir = '/proc/self/fd'; + const countFds = () => { + try { return fs.readdirSync(fdDir).length; } catch { return -1; } + }; + const before = countFds(); + + // Load repeatedly: each load materializes its own image, so a leak of an + // image, a descriptor or a retained handle shows up as growth. + for (let i = 0; i < 5; i++) { + const m = { exports: {} }; + // flags undefined: keep the default dlopen(2) mode - 0 is not valid + // everywhere (glibc rejects it with EINVAL). + dlopenBinary(m, virtualPath, undefined, bytes); + if (m.exports.hello() !== 'world') throw new Error('addon did not load'); + } + + const after = countFds(); + if (before !== -1 && after > before) { + throw new Error(\`descriptor leak: \${before} -> \${after} after 5 loads\`); + } + process.exit(0); +`; + +const res = spawnSync(process.execPath, ['--expose-internals', '-e', child], { + env: { ...process.env, TMPDIR: imageDir, TMP: imageDir, TEMP: imageDir }, + encoding: 'utf8', +}); + +// The load itself must succeed. On Windows a retained writable handle makes the +// loader fail with ERROR_SHARING_VIOLATION ("The process cannot access the file +// because it is being used by another process"). +assert.strictEqual(res.status, 0, `child failed:\n${res.stderr}`); + +// Nothing an image left behind may outlive the process that created it. Match +// the shapes the two on-disk paths produce rather than requiring the directory +// to be empty, so an unrelated temp file cannot fail this. +const leftovers = fs.readdirSync(imageDir).filter( + (name) => /^nod.*\.tmp$/i.test(name) || name.startsWith('node-addon-')); +assert.deepStrictEqual( + leftovers, [], + `materialized addon image outlived the process that loaded it: ${leftovers}`); diff --git a/test/parallel/test-vfs-addon.js b/test/parallel/test-vfs-addon.js index 1c138a187d0a..7ea8fdbf7943 100644 --- a/test/parallel/test-vfs-addon.js +++ b/test/parallel/test-vfs-addon.js @@ -34,4 +34,12 @@ if (process.platform !== 'win32') { assert.deepStrictEqual(leaked, [], `addon temp not cleaned up: ${leaked}`); } +// Regression check: while a VFS is mounted, process.dlopen() of a +// real-file-system addon without a flags argument must keep the default +// flags rather than forwarding `undefined`, which coerces to 0 - not a +// valid dlopen(2) mode. +const realMod = { exports: {} }; +process.dlopen(realMod, addonPath); +assert.strictEqual(realMod.exports.hello(), 'world'); + myVfs.unmount();