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();