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..5520480293d5 100644 --- a/src/node_binding.cc +++ b/src/node_binding.cc @@ -9,6 +9,7 @@ #include "util.h" #include +#include #include #ifdef _WIN32 @@ -474,64 +475,29 @@ int NodeMemfdCreate(const char* name, unsigned int flags) { return static_cast(syscall(SYS_memfd_create, name, flags)); } #endif // __linux__ +#else // _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; + #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; +// 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. + +AddonImage::AddonImage() { #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 + handle_ = INVALID_HANDLE_VALUE; #endif -}; +} #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]; @@ -730,8 +696,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]). diff --git a/src/node_binding.h b/src/node_binding.h index c200cc0d0c8a..3e7d8f64b916 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,58 @@ 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 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 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(); + ~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. 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); + + private: + std::string path_; + std::string errmsg_; + bool consumed_ = false; +#ifdef _WIN32 + void* handle_; // HANDLE; void* keeps windows.h out of this header +#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..dbdec8d92563 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,43 @@ 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); + 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-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();