Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions doc/api/ffi.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,11 @@ const path = `libsqlite3.${suffix}`;

<!-- YAML
added: v26.1.0
changes:
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/65909
description: Library paths inside a mounted virtual file system are now
supported.
-->

* `path` {string|null} Path to a dynamic library, or `null` to resolve symbols
Expand All @@ -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.

Expand Down Expand Up @@ -302,13 +314,24 @@ Represents a loaded dynamic library.

### `new DynamicLibrary(path)`

<!-- YAML
changes:
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/65909
description: Library paths inside a mounted virtual file system are now
supported.
-->

* `path` {string|null} Path to a dynamic library, or `null` to resolve symbols
from the current process image.

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

Expand Down Expand Up @@ -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
8 changes: 8 additions & 0 deletions doc/api/vfs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
53 changes: 52 additions & 1 deletion lib/ffi.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,15 @@ const {
ObjectGetOwnPropertyDescriptor,
ObjectKeys,
ObjectPrototypeToString,
ReflectConstruct,
SafeWeakMap,
SafeWeakRef,
StringPrototypeStartsWith,
SymbolDispose,
TypedArrayPrototypeGetBuffer,
} = primordials;
const { Buffer } = require('buffer');
const { resolve, sep, toNamespacedPath } = require('path');
const { emitExperimentalWarning } = require('internal/util');
const {
isDataView,
Expand All @@ -38,7 +41,7 @@ const {
emitExperimentalWarning('FFI');

const {
DynamicLibrary,
DynamicLibrary: NativeDynamicLibrary,
getInt8,
getUint8,
getInt16,
Expand Down Expand Up @@ -119,6 +122,54 @@ function wrapFFIFunction(rawFn, owner) {
return wrapped;
}

let vfsRootPrefix;

// Returns the library's bytes when `path` names a file inside a mounted
// virtual file system, which the dynamic loader cannot open by path, or
// undefined when the dynamic loader should open the path itself. All VFS
// mount points live under a reserved root that no real path can fall
// under, so a prefix test decides ownership and the VFS machinery is only
// loaded for paths under that root.
function readVirtualLibrary(path) {
if (typeof path !== 'string') {
return undefined;
}
if (vfsRootPrefix === undefined) {
const { getNormalizedVfsRoot } = require('internal/vfs/router');
vfsRootPrefix = getNormalizedVfsRoot() + sep;
}
if (!StringPrototypeStartsWith(toNamespacedPath(resolve(path)),
vfsRootPrefix)) {
return undefined;
}
const { readVirtualBinary } = require('internal/vfs/setup');
return readVirtualBinary(path);
}

// 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 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 binary = 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;
Expand Down
31 changes: 31 additions & 0 deletions lib/internal/vfs/setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -968,10 +968,40 @@ 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. Used by node:ffi
* to 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);
// Hooks may not be installed (no VFS mounted): compute the reserved-root
// prefix locally instead of relying on the install-time global.
const rootPrefix = normalizedVfsRootPrefix ?? (getNormalizedVfsRoot() + sep);
if (!StringPrototypeStartsWith(normalized, rootPrefix)) {
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.
*/
Expand Down Expand Up @@ -1005,4 +1035,5 @@ function uninstallHooks() {
module.exports = {
registerVFS,
deregisterVFS,
readVirtualBinary,
};
69 changes: 17 additions & 52 deletions src/node_binding.cc
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
#include "util.h"

#include <string>
#include <utility>
#include <vector>

#ifdef _WIN32
Expand Down Expand Up @@ -474,64 +475,29 @@ int NodeMemfdCreate(const char* name, unsigned int flags) {
return static_cast<int>(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<HANDLE>* 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<HANDLE>* g_retained_addon_handles = nullptr;

// static
std::string AddonImage::TempDir() {
wchar_t dir[MAX_PATH + 1];
Expand Down Expand Up @@ -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]).
Expand Down Expand Up @@ -928,6 +892,7 @@ void DLOpenBinary(const FunctionCallbackInfo<Value>& args) {
DLOpenImpl(args, true);
}


inline struct node_module* FindModule(struct node_module* list,
const char* name,
int flag) {
Expand Down
54 changes: 54 additions & 0 deletions src/node_binding.h
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
#include <dlfcn.h>
#endif

#include <string>

#include "node.h"
#include "node_api.h"
#include "quic/guard.h"
Expand Down Expand Up @@ -170,6 +172,58 @@ void GetLinkedBinding(const v8::FunctionCallbackInfo<v8::Value>& args);
void DLOpen(const v8::FunctionCallbackInfo<v8::Value>& args);
void DLOpenBinary(const v8::FunctionCallbackInfo<v8::Value>& 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
Expand Down
Loading