vfs: load native addons from a mounted file system

A native addon inside a virtual file system could not be require()d:
dlopen() and LoadLibrary() open a shared object by path, and a VFS path
has no inode for them to open.

Read the addon's bytes from the VFS and load them from a private,
self-cleaning image instead, using the smallest on-disk footprint each
platform allows: on Linux an anonymous memfd loaded through
/proc/self/fd, so the bytes never reach the file system; on other POSIX
platforms a file in a 0700 mkdtemp() directory, unlinked right after
loading, with the mapping keeping it alive; on Windows a temp file
opened FILE_FLAG_DELETE_ON_CLOSE whose handle is held until exit.

This is internal. process.dlopen() keeps its documented
(module, filename[, flags]) signature and ignores anything further; the
bytes are passed to a dlopenBinary() reachable only through the
process_methods binding, which the VFS hook calls for VFS paths. Addons
on the real file system are untouched and load directly.

Signed-off-by: Philipp Dunkel <pip@pipobscure.com>
PR-URL: https://github.com/nodejs/node/pull/65680
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: Paolo Insogna <paolo@cowtech.it>
Reviewed-By: James M Snell <jasnell@gmail.com>
This commit is contained in:
Philipp Dunkel authored and GitHub committed 2026-09-04 16:48:33 +00:00
1 parent dd0f2032e6
commit ee36353428
12 files changed
+627 -6

No files matched your search

+23
View File
@@ -268,6 +268,26 @@ process.permission.has('fs.read', 'custom-require.js'); // true
process.permission.has('fs.read', 'custom-require-2.js'); // true
```
### `--allow-fs-vfs`
<!-- YAML
added: REPLACEME
-->
> Stability: 1.1 - Active development
When using the [Permission Model][], a [virtual file system][] cannot be
mounted by default: [`vfs.mount()`][] throws `ERR_INVALID_STATE` unless the
user explicitly passes the `--allow-fs-vfs` flag when starting Node.js.
A mounted VFS serves paths that the file system permissions do not describe,
so mounting one is gated on its own flag rather than on `--allow-fs-read` or
`--allow-fs-write`.
```console
$ node --experimental-vfs --permission --allow-fs-vfs app.js
```
### `--allow-fs-write`
<!-- YAML
@@ -4021,6 +4041,7 @@ one is included in the list below.
* `--allow-child-process`
* `--allow-ffi`
* `--allow-fs-read`
* `--allow-fs-vfs`
* `--allow-fs-write`
* `--allow-inspector`
* `--allow-net`
@@ -4741,6 +4762,7 @@ node --stack-trace-limit=12 -p -e "Error.stackTraceLimit" # prints 12
[`v8.startupSnapshot.addDeserializeCallback()`]: v8.md#v8startupsnapshotadddeserializecallbackcallback-data
[`v8.startupSnapshot.setDeserializeMainFunction()`]: v8.md#v8startupsnapshotsetdeserializemainfunctioncallback-data
[`v8.startupSnapshot` API]: v8.md#startup-snapshot-api
[`vfs.mount()`]: vfs.md#vfsmount
[asynchronous module customization hooks]: module.md#asynchronous-customization-hooks
[benchmark runner]: bench.md#command-line-runner
[captured by the built-in snapshot of Node.js]: https://github.com/nodejs/node/blob/b19525a33cc84033af4addd0f80acd4dc33ce0cf/test/parallel/test-bootstrap-modules.js#L24
@@ -4773,4 +4795,5 @@ node --stack-trace-limit=12 -p -e "Error.stackTraceLimit" # prints 12
[test runner execution model]: test.md#test-runner-execution-model
[timezone IDs]: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
[tracking issue for user-land snapshots]: https://github.com/nodejs/node/issues/44014
[virtual file system]: vfs.md
[ways that `TZ` is handled in other environments]: https://www.gnu.org/software/libc/manual/html_node/TZ-Variable.html
+6
View File
@@ -419,6 +419,12 @@ system, the callers are responsible for avoiding removal or
invalidation of modules in the virtual file system while they are
being loaded.
Native addons (`.node` files) stored in a mounted VFS can be `require()`d as
well. The operating system's dynamic loader cannot open a virtual path, so the
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.
## Use with Single Executable Applications
When running as a [Single Executable Application][] built with
+13
View File
@@ -166,6 +166,17 @@ process.permission.has('fs.read', 'custom-require.js'); // true
process.permission.has('fs.read', 'custom-require-2.js'); // true
.Ed
.
.It Fl -allow-fs-vfs
When using the Permission Model, a virtual file system cannot be
mounted by default: \fBvfs.mount()\fR throws \fBERR_INVALID_STATE\fR unless the
user explicitly passes the \fB--allow-fs-vfs\fR flag when starting Node.js.
A mounted VFS serves paths that the file system permissions do not describe,
so mounting one is gated on its own flag rather than on \fB--allow-fs-read\fR or
\fB--allow-fs-write\fR.
.Bd -literal
$ node --experimental-vfs --permission --allow-fs-vfs app.js
.Ed
.
.It Fl -allow-fs-write
This flag configures file system write permissions using
the Permission Model.
@@ -2018,6 +2029,8 @@ one is included in the list below.
.It
\fB--allow-fs-read\fR
.It
\fB--allow-fs-vfs\fR
.It
\fB--allow-fs-write\fR
.It
\fB--allow-inspector\fR
+22
View File
@@ -914,6 +914,26 @@ function installModuleLoaderOverrides() {
});
}
let originalDlopen;
function installAddonLoader() {
originalDlopen = process.dlopen;
process.dlopen = function(module, filename, flags) {
// dlopen(2) cannot open a native addon that lives in a VFS by path (it has
// no real inode). Read its bytes and hand them to the internal
// dlopenBinary(), which writes them to a private, self-cleaning temporary
// image - an in-memory memfd on Linux - and loads that. Only VFS paths take
// this route; everything else loads straight from disk through the
// unchanged process.dlopen().
if (StringPrototypeStartsWith(filename, normalizedVfsRootPrefix)) {
const { readFileSync } = require('fs');
const { dlopenBinary } = internalBinding('process_methods');
return dlopenBinary(module, filename, flags, readFileSync(filename));
}
return originalDlopen(module, filename, flags);
};
}
/**
* Install all VFS hooks: module loader overrides and fs handlers.
*/
@@ -922,6 +942,7 @@ function installHooks() {
debug('install hooks');
normalizedVfsRootPrefix = getNormalizedVfsRoot() + sep;
installModuleLoaderOverrides();
installAddonLoader();
vfsHandlerObj = createVfsHandlers();
setVfsHandlers(vfsHandlerObj);
hooksInstalled = true;
@@ -939,6 +960,7 @@ function uninstallHooks() {
setLoaderOverrides();
setVfsHandlers(null);
vfsHandlerObj = undefined;
process.dlopen = originalDlopen;
hooksInstalled = false;
}
+352 -6
View File
@@ -9,6 +9,20 @@
#include "util.h"
#include <string>
#include <vector>
#ifdef _WIN32
#include <windows.h>
#else
#include <fcntl.h>
#include <unistd.h>
#include <cerrno>
#include <cstdlib>
#if defined(__linux__)
#include <sys/mman.h>
#include <sys/syscall.h>
#endif
#endif
#if HAVE_OPENSSL
#define NODE_BUILTIN_OPENSSL_BINDINGS(V) V(crypto) V(tls_wrap)
@@ -438,13 +452,295 @@ inline node_api_addon_get_api_version_func GetNapiAddonGetApiVersionCallback(
dlib->GetSymbolAddress(STRINGIFY(NODE_API_MODULE_GET_API_VERSION)));
}
// DLOpen is process.dlopen(module, filename, flags).
// Used to load 'module.node' dynamically shared objects.
namespace {
#ifndef _WIN32
// Write the whole buffer to `fd`, retrying partial writes and EINTR.
bool WriteAllToFd(int fd, const char* data, size_t len) {
size_t off = 0;
while (off < len) {
ssize_t n = write(fd, data + off, len - off);
if (n < 0) {
if (errno == EINTR) continue;
return false;
}
off += static_cast<size_t>(n);
}
return true;
}
#if defined(__linux__)
int NodeMemfdCreate(const char* name, unsigned int flags) {
return static_cast<int>(syscall(SYS_memfd_create, name, flags));
}
#endif // __linux__
#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);
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
};
#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];
DWORD dir_len = GetTempPathW(MAX_PATH + 1, dir);
if (dir_len == 0 || dir_len > MAX_PATH) return std::string();
int size = WideCharToMultiByte(
CP_UTF8, 0, dir, dir_len, nullptr, 0, nullptr, nullptr);
if (size <= 0) return std::string();
std::string out(size, '\0');
WideCharToMultiByte(
CP_UTF8, 0, dir, dir_len, out.data(), size, nullptr, nullptr);
return out;
}
bool AddonImage::Materialize(const char* data, size_t len) {
wchar_t dir[MAX_PATH + 1];
DWORD dir_len = GetTempPathW(MAX_PATH + 1, dir);
if (dir_len == 0 || dir_len > MAX_PATH) {
errmsg_ = "could not locate the temporary directory";
return false;
}
wchar_t file[MAX_PATH + 1];
if (GetTempFileNameW(dir, L"nod", 0, file) == 0) {
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) {
errmsg_ = "could not create a temporary file for the native addon";
return false;
}
size_t off = 0;
while (off < len) {
DWORD chunk =
len - off > MAXDWORD ? MAXDWORD : static_cast<DWORD>(len - off);
DWORD written = 0;
if (!WriteFile(handle_, data + off, chunk, &written, nullptr)) {
errmsg_ = "could not write the native addon to a temporary file";
CloseHandle(handle_);
handle_ = INVALID_HANDLE_VALUE;
return false;
}
off += written;
}
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;
return false;
}
path_.resize(utf8_len - 1);
WideCharToMultiByte(
CP_UTF8, 0, file, -1, path_.data(), utf8_len, nullptr, nullptr);
return true;
}
void AddonImage::AfterOpen(bool opened) {
consumed_ = true;
if (handle_ == INVALID_HANDLE_VALUE) return;
if (!opened) {
CloseHandle(handle_); // delete-on-close removes the file
handle_ = INVALID_HANDLE_VALUE;
return;
}
Mutex::ScopedLock lock(g_retained_addon_handles_mutex);
if (g_retained_addon_handles == nullptr) {
g_retained_addon_handles = new std::vector<HANDLE>();
}
g_retained_addon_handles->push_back(handle_);
handle_ = INVALID_HANDLE_VALUE;
}
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_);
}
#else // !_WIN32
bool AddonImage::Materialize(const char* data, size_t len) {
#if defined(__linux__)
// Prefer an anonymous in-memory fd, loaded through /proc/self/fd: nothing
// reaches the filesystem, so there is no temp file to secure, unlink, or
// leak, and no noexec-mount problem. Request an executable memfd (MFD_EXEC,
// Linux 6.3+); retry without it on older kernels that reject the flag, then
// fall back to a temp file if memfd is unavailable entirely.
#ifndef MFD_CLOEXEC
#define MFD_CLOEXEC 0x0001U
#endif
#ifndef MFD_EXEC
#define MFD_EXEC 0x0010U
#endif
fd_ = NodeMemfdCreate("node-addon", MFD_CLOEXEC | MFD_EXEC);
if (fd_ == -1 && errno == EINVAL) {
fd_ = NodeMemfdCreate("node-addon", MFD_CLOEXEC);
}
if (fd_ != -1) {
if (!WriteAllToFd(fd_, data, len)) {
errmsg_ = "could not write the native addon to an in-memory file";
close(fd_);
fd_ = -1;
return false;
}
path_ = "/proc/self/fd/" + std::to_string(fd_);
return true;
}
#endif // __linux__
return MaterializeTempFile(data, len);
}
// static
std::string AddonImage::TempDir() {
const char* env_tmp = getenv("TMPDIR");
std::string tmpdir =
(env_tmp != nullptr && *env_tmp != '\0') ? env_tmp : "/tmp";
if (tmpdir.back() != '/') tmpdir.push_back('/');
return tmpdir;
}
bool AddonImage::MaterializeTempFile(const char* data, size_t len) {
// A private 0700 directory (mkdtemp) so no other user can pre-create the path
// as a symlink or swap the file between the write and the load; O_EXCL and
// O_NOFOLLOW harden the create against a race inside it.
std::string tmpl = TempDir() + "node-addon-XXXXXX";
std::vector<char> buf(tmpl.begin(), tmpl.end());
buf.push_back('\0');
if (mkdtemp(buf.data()) == nullptr) {
errmsg_ = "could not create a temporary directory for the native addon";
return false;
}
temp_dir_ = buf.data();
std::string file = temp_dir_ + "/addon.node";
fd_ = open(
file.c_str(), O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, 0600);
if (fd_ == -1) {
errmsg_ = "could not create a temporary file for the native addon";
rmdir(temp_dir_.c_str());
temp_dir_.clear();
return false;
}
if (!WriteAllToFd(fd_, data, len)) {
errmsg_ = "could not write the native addon to a temporary file";
close(fd_);
fd_ = -1;
unlink(file.c_str());
rmdir(temp_dir_.c_str());
temp_dir_.clear();
return false;
}
// dlopen() reopens by path; the post-open mapping keeps the inode alive.
close(fd_);
fd_ = -1;
path_ = file;
return true;
}
void AddonImage::AfterOpen(bool opened) {
consumed_ = true;
// The right cleanup is the same whether or not the load worked.
(void)opened;
// memfd: the load's mapping (or nothing, on failure) owns it from here.
if (fd_ != -1) {
close(fd_);
fd_ = -1;
return;
}
if (!temp_dir_.empty()) {
// On success the mapping keeps the inode alive, so unlinking now leaves no
// on-disk trace; on failure this just cleans up.
unlink(path_.c_str());
rmdir(temp_dir_.c_str());
temp_dir_.clear();
}
}
AddonImage::~AddonImage() {
if (consumed_) return;
if (fd_ != -1) close(fd_);
if (!temp_dir_.empty()) {
unlink(path_.c_str());
rmdir(temp_dir_.c_str());
}
}
#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]).
//
// FIXME(bnoordhuis) Not multi-context ready. TBD how to resolve the conflict
// when two contexts try to load the same shared object. Maybe have a shadow
// cache that's a plain C list or hash table that's shared across contexts?
void DLOpen(const FunctionCallbackInfo<Value>& args) {
static void DLOpenImpl(const FunctionCallbackInfo<Value>& args,
bool allow_binary) {
Environment* env = Environment::GetCurrent(args);
if (env->no_native_addons()) {
@@ -464,7 +760,11 @@ void DLOpen(const FunctionCallbackInfo<Value>& args) {
}
int32_t flags = DLib::kDefaultFlags;
if (args.Length() > 2 && !args[2]->Int32Value(context).To(&flags)) {
// On the internal path an undefined flags argument keeps the default, so a
// caller can pass the bytes without choosing flags. process.dlopen() keeps
// its original behaviour of rejecting any non-integer that is present.
if (args.Length() > 2 && !(allow_binary && args[2]->IsUndefined()) &&
!args[2]->Int32Value(context).To(&flags)) {
return THROW_ERR_INVALID_ARG_TYPE(env, "flag argument must be an integer.");
}
@@ -477,12 +777,44 @@ void DLOpen(const FunctionCallbackInfo<Value>& args) {
return; // Exception pending.
}
node::Utf8Value filename(env->isolate(), args[1]); // Cast
env->TryLoadAddon(*filename, flags, [&](DLib* dlib) {
node::Utf8Value filename(env->isolate(), args[1]); // The addon's path,
// used for diagnostics.
// On the internal path args[3] carries the addon's bytes, for an addon that
// lives somewhere dlopen() cannot open by path (e.g. a virtual file system).
// Materialize them into a private, self-cleaning image and load that, while
// still reporting `filename` in any error.
AddonImage image;
const char* load_path = *filename;
if (allow_binary && args.Length() > 3 && !args[3]->IsUndefined()) {
if (!args[3]->IsArrayBufferView()) {
return THROW_ERR_INVALID_ARG_TYPE(
env, "binary must be a Buffer, TypedArray, or DataView");
}
// Loading from bytes materializes them into an image in the temporary
// directory, so this needs write access there on top of the addon
// 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,
AddonImage::TempDir());
ArrayBufferViewContents<char> binary(args[3]);
if (!image.Materialize(binary.data(), binary.length())) {
return THROW_ERR_DLOPEN_FAILED(
env, "%s: %s", image.errmsg().c_str(), *filename);
}
load_path = image.path().c_str();
}
env->TryLoadAddon(load_path, flags, [&](DLib* dlib) {
static Mutex dlib_load_mutex;
Mutex::ScopedLock lock(dlib_load_mutex);
const bool is_opened = dlib->Open();
image.AfterOpen(is_opened);
// Objects containing v14 or later modules will have registered themselves
// on the pending list. Activate all of them now. At present, only one
@@ -582,6 +914,20 @@ void DLOpen(const FunctionCallbackInfo<Value>& args) {
// coverity[leaked_storage]
}
// process.dlopen(module, filename[, flags]). Used to load 'module.node'
// dynamically shared objects.
void DLOpen(const FunctionCallbackInfo<Value>& args) {
DLOpenImpl(args, false);
}
// Internal only, reached through the process_methods binding rather than the
// process object: dlopenBinary(module, filename, flags, binary) loads an addon
// from bytes already in memory. Used for addons served by a virtual file
// system, which the dynamic loader cannot open by path.
void DLOpenBinary(const FunctionCallbackInfo<Value>& args) {
DLOpenImpl(args, true);
}
inline struct node_module* FindModule(struct node_module* list,
const char* name,
int flag) {
+1
View File
@@ -168,6 +168,7 @@ void CreateInternalBindingTemplates(IsolateData* isolate_data);
void GetInternalBinding(const v8::FunctionCallbackInfo<v8::Value>& args);
void GetLinkedBinding(const v8::FunctionCallbackInfo<v8::Value>& args);
void DLOpen(const v8::FunctionCallbackInfo<v8::Value>& args);
void DLOpenBinary(const v8::FunctionCallbackInfo<v8::Value>& args);
} // namespace binding
+6
View File
@@ -738,6 +738,12 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() {
&EnvironmentOptions::allow_fs_write,
kAllowedInEnvvar,
OptionNamespaces::kPermissionNamespace);
AddOption("--allow-fs-vfs",
"allow mounting a virtual file system when any permissions are set",
BOOL_FIELD(allow_fs_vfs),
kAllowedInEnvvar,
false,
OptionNamespaces::kPermissionNamespace);
AddOption("--allow-addons",
"allow use of addons when any permissions are set",
BOOL_FIELD(allow_addons),
+1
View File
@@ -225,6 +225,7 @@ class EnvironmentOptions : public Options {
DEFINE_BOOL_FIELD(permission) = false;
DEFINE_BOOL_FIELD(permission_audit) = false;
DEFINE_BOOL_FIELD(allow_addons) = false;
DEFINE_BOOL_FIELD(allow_fs_vfs) = false;
DEFINE_BOOL_FIELD(allow_inspector) = false;
DEFINE_BOOL_FIELD(allow_child_process) = false;
DEFINE_BOOL_FIELD(allow_net) = false;
+2
View File
@@ -807,6 +807,7 @@ static void CreatePerIsolateProperties(IsolateData* isolate_data,
SetMethodNoSideEffect(isolate, target, "cwd", Cwd);
SetMethod(isolate, target, "dlopen", binding::DLOpen);
SetMethod(isolate, target, "dlopenBinary", binding::DLOpenBinary);
SetMethod(isolate, target, "reallyExit", ReallyExit);
#if defined __POSIX__ && !defined(__PASE__)
@@ -854,6 +855,7 @@ void RegisterExternalReferences(ExternalReferenceRegistry* registry) {
registry->Register(Cwd);
registry->Register(binding::DLOpen);
registry->Register(binding::DLOpenBinary);
registry->Register(ReallyExit);
#if defined __POSIX__ && !defined(__PASE__)
+51
View File
@@ -0,0 +1,51 @@
// Flags: --expose-internals
'use strict';
const common = require('../common');
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { internalBinding } = require('internal/test/binding');
// dlopenBinary() loads a native addon from bytes already in memory, for an
// addon that the dynamic loader cannot open by path (e.g. one served by a
// virtual file system). It is internal: process.dlopen() keeps its documented
// (module, filename[, flags]) signature and never takes the bytes.
const { dlopenBinary } = internalBinding('process_methods');
assert.strictEqual(typeof dlopenBinary, 'function');
assert.strictEqual(process.dlopen.length, 0);
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');
const bytes = fs.readFileSync(addonPath);
const flags = (os.constants.dlopen?.RTLD_LAZY) || 0;
// A path that does not exist on disk, as a VFS-resident addon would be.
const fakePath = path.join(os.tmpdir(), 'nonexistent-vfs-dir', 'binding.node');
assert.ok(!fs.existsSync(fakePath));
const m = { exports: {} };
dlopenBinary(m, fakePath, flags, bytes);
assert.strictEqual(typeof m.exports.hello, 'function');
assert.strictEqual(m.exports.hello(), 'world');
// The flags argument may be left undefined when passing bytes.
const m2 = { exports: {} };
dlopenBinary(m2, fakePath, undefined, bytes);
assert.strictEqual(m2.exports.hello(), 'world');
// Without the bytes, that nonexistent path cannot be loaded.
assert.throws(() => dlopenBinary({ exports: {} }, fakePath, flags),
{ code: 'ERR_DLOPEN_FAILED' });
// A non-buffer binary is rejected.
assert.throws(
() => dlopenBinary({ exports: {} }, fakePath, flags, 'nope'),
{ code: 'ERR_INVALID_ARG_TYPE' });
// process.dlopen() ignores a fourth argument rather than loading from it: the
// public signature is unchanged, so this still fails on the missing path.
assert.throws(
() => process.dlopen({ exports: {} }, fakePath, flags, bytes),
{ code: 'ERR_DLOPEN_FAILED' });
@@ -0,0 +1,113 @@
// Flags: --expose-internals
'use strict';
// Loading an addon from bytes materializes them into an image in the temporary
// directory, so dlopenBinary() requires file-system write access on top of the
// addon permission every dlopen already needs. Granting only one of the two is
// not enough.
const common = require('../common');
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
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');
// Loads the addon from bytes at a path that does not exist on disk, as an
// addon served by a virtual file system would be, and reports what happened.
const child = `
const { internalBinding } = require('internal/test/binding');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { dlopenBinary } = internalBinding('process_methods');
const bytes = fs.readFileSync(${JSON.stringify(addonPath)});
const target = path.join(os.tmpdir(), 'nonexistent-vfs-dir', 'binding.node');
const m = { exports: {} };
try {
dlopenBinary(m, target, undefined, bytes);
console.log('LOADED', m.exports.hello());
} catch (err) {
console.log('DENIED', err.code, err.permission ?? '');
}
`;
function run(...permissions) {
return spawnSync(
process.execPath,
['--expose-internals', '--permission', '--allow-fs-read=*',
...permissions, '-e', child],
{ encoding: 'utf8' });
}
// Both permissions granted: the addon loads.
{
const res = run('--allow-addons', '--allow-fs-write=*');
assert.strictEqual(res.status, 0, res.stderr);
assert.match(res.stdout, /LOADED world/);
}
// Addons allowed but no write access: refused by the file-system check, so the
// bytes are never materialized.
{
const res = run('--allow-addons');
assert.strictEqual(res.status, 0, res.stderr);
assert.match(res.stdout, /DENIED ERR_ACCESS_DENIED FileSystemWrite/);
}
// Write access but addons disallowed: refused before the bytes are looked at,
// the same as any other dlopen under the permission model.
{
const res = run('--allow-fs-write=*');
assert.strictEqual(res.status, 0, res.stderr);
assert.match(res.stdout, /DENIED ERR_DLOPEN_DISABLED/);
}
// Without the permission model neither check applies.
{
const res = spawnSync(process.execPath, ['--expose-internals', '-e', child],
{ encoding: 'utf8' });
assert.strictEqual(res.status, 0, res.stderr);
assert.match(res.stdout, /LOADED world/);
}
// The same, reached the way a program actually gets here: an addon inside a
// mounted VFS. Mounting one under the permission model needs --allow-fs-vfs.
{
const mounted = `
const fs = require('fs');
const vfs = require('node:vfs');
const myVfs = vfs.create();
myVfs.writeFileSync('/binding.node',
fs.readFileSync(${JSON.stringify(addonPath)}));
const mountPoint = myVfs.mount();
try {
console.log('LOADED', require(mountPoint + '/binding.node').hello());
} catch (err) {
console.log('DENIED', err.code, err.permission ?? '');
}
`;
const vfsRun = (...permissions) => spawnSync(
process.execPath,
['--experimental-vfs', '--permission', '--allow-fs-read=*',
...permissions, '-e', mounted],
{ encoding: 'utf8' });
const ok = vfsRun('--allow-fs-vfs', '--allow-addons', '--allow-fs-write=*');
assert.strictEqual(ok.status, 0, ok.stderr);
assert.match(ok.stdout, /LOADED world/);
// Mounting itself is refused without --allow-fs-vfs.
const noVfs = vfsRun('--allow-addons', '--allow-fs-write=*');
assert.notStrictEqual(noVfs.status, 0);
assert.match(noVfs.stderr, /ERR_INVALID_STATE/);
assert.match(noVfs.stderr, /--allow-fs-vfs/);
// Mounted, addons allowed, but no write access: the addon is refused.
const noWrite = vfsRun('--allow-fs-vfs', '--allow-addons');
assert.strictEqual(noWrite.status, 0, noWrite.stderr);
assert.match(noWrite.stdout, /DENIED ERR_ACCESS_DENIED FileSystemWrite/);
}
+37
View File
@@ -0,0 +1,37 @@
// Flags: --experimental-vfs
'use strict';
const common = require('../common');
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const vfs = require('node:vfs');
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');
}
// A native addon inside a mounted VFS can be require()d: dlopen() cannot open
// the reserved mount path, so the loader hands the addon's bytes to
// process.dlopen's `binary` option, which loads them from a private temporary
// image (an in-memory memfd on Linux).
const myVfs = vfs.create();
myVfs.writeFileSync('/binding.node', fs.readFileSync(addonPath));
const mountPoint = myVfs.mount();
const before = new Set(fs.readdirSync(os.tmpdir()));
const addon = require(path.join(mountPoint, 'binding.node'));
assert.strictEqual(addon.hello(), 'world');
// On Linux the memfd path never touches the filesystem; on other POSIX the
// temp image is unlinked right after loading, so no addon temp file lingers.
// (Windows keeps a delete-on-close file until exit, so skip the check there.)
if (process.platform !== 'win32') {
const leaked = fs.readdirSync(os.tmpdir())
.filter((f) => f.startsWith('node-addon') && !before.has(f));
assert.deepStrictEqual(leaked, [], `addon temp not cleaned up: ${leaked}`);
}
myVfs.unmount();