diff --git a/doc/api/crypto.md b/doc/api/crypto.md index c53d4d649778..202f49910cda 100644 --- a/doc/api/crypto.md +++ b/doc/api/crypto.md @@ -3806,6 +3806,8 @@ behavior. For XOF hash functions such as `'shake256'`, the `outputLength` option specifies the desired output length in bytes. It is required for XOF hash functions without a default output length. +When the data is small (< 5MB) and readily available, [`crypto.hash()`][] is usually faster. + The `algorithm` is dependent on the available algorithms supported by the version of OpenSSL on the platform. Examples are `'sha256'`, `'sha512'`, etc. On recent releases of OpenSSL, `openssl list -digest-algorithms` will @@ -7018,6 +7020,7 @@ See the [list of SSL OP Flags][] for details. [`crypto.getCurves()`]: #cryptogetcurves [`crypto.getDiffieHellman()`]: #cryptogetdiffiehellmangroupname [`crypto.getHashes()`]: #cryptogethashes +[`crypto.hash()`]: #cryptohashalgorithm-data-options [`crypto.privateDecrypt()`]: #cryptoprivatedecryptprivatekey-buffer [`crypto.privateEncrypt()`]: #cryptoprivateencryptprivatekey-buffer [`crypto.publicDecrypt()`]: #cryptopublicdecryptkey-buffer diff --git a/doc/api/single-executable-applications.md b/doc/api/single-executable-applications.md index 7480a87d43a5..be5667cbd499 100644 --- a/doc/api/single-executable-applications.md +++ b/doc/api/single-executable-applications.md @@ -71,7 +71,7 @@ binary. * On macOS: ```bash - codesign --sign - hello + codesign --sign - sea ``` * On Windows (optional): @@ -80,7 +80,7 @@ binary. binary would still be runnable. ```powershell - signtool sign /fd SHA256 hello.exe + signtool sign /fd SHA256 sea.exe ``` 5. Run the binary: @@ -88,14 +88,14 @@ binary. * On systems other than Windows ```console - $ ./hello world + $ ./sea world Hello, world! ``` * On Windows ```console - $ .\hello.exe world + $ .\sea.exe world Hello, world! ``` diff --git a/doc/api/vm.md b/doc/api/vm.md index 29d9bc732d61..14cd1f269b12 100644 --- a/doc/api/vm.md +++ b/doc/api/vm.md @@ -919,6 +919,7 @@ const contextifiedObject = vm.createContext({ secret: 42 }); const module = new vm.SourceTextModule( 'Object.getPrototypeOf(import.meta.prop).secret = secret;', { + context: contextifiedObject, initializeImportMeta(meta) { // Note: this object is created in the top context. As such, // Object.getPrototypeOf(import.meta.prop) points to the @@ -937,7 +938,7 @@ await module.evaluate(); // To fix this problem, replace // meta.prop = {}; // above with -// meta.prop = vm.runInContext('{}', contextifiedObject); +// meta.prop = vm.runInContext('({})', contextifiedObject); ``` ```cjs @@ -947,6 +948,7 @@ const contextifiedObject = vm.createContext({ secret: 42 }); const module = new vm.SourceTextModule( 'Object.getPrototypeOf(import.meta.prop).secret = secret;', { + context: contextifiedObject, initializeImportMeta(meta) { // Note: this object is created in the top context. As such, // Object.getPrototypeOf(import.meta.prop) points to the @@ -964,7 +966,7 @@ const contextifiedObject = vm.createContext({ secret: 42 }); // To fix this problem, replace // meta.prop = {}; // above with - // meta.prop = vm.runInContext('{}', contextifiedObject); + // meta.prop = vm.runInContext('({})', contextifiedObject); })(); ``` diff --git a/lib/internal/vfs/providers/memory.js b/lib/internal/vfs/providers/memory.js index 8490340101bc..a46d1e71e843 100644 --- a/lib/internal/vfs/providers/memory.js +++ b/lib/internal/vfs/providers/memory.js @@ -2,6 +2,7 @@ const { ArrayFrom, + ArrayPrototypePop, ArrayPrototypePush, DateNow, SafeMap, @@ -613,59 +614,73 @@ class MemoryProvider extends VirtualProvider { */ #readdirRecursive(dirEntry, dirPath, withFileTypes) { const results = []; + // Directories on the current traversal path. A directory reached again + // through a symlink cycle is not descended into (but is still listed). const active = new SafeSet(); - const walk = (entry, currentPath, relativePath) => { - if (active.has(entry)) { - return; + // Traverse depth-first with an explicit stack instead of recursion, so a + // deeply nested tree cannot exhaust the call stack. Each frame is a + // directory being walked together with a snapshot of its children and the + // index of the next child to visit. + const enter = (entry, currentPath, relativePath) => { + this.#ensurePopulated(entry, currentPath); + active.add(entry); + ArrayPrototypePush(stack, { + entry, + currentPath, + relativePath, + children: ArrayFrom(entry.children), + index: 0, + }); + }; + + const stack = []; + enter(dirEntry, dirPath, ''); + + while (stack.length > 0) { + const frame = stack[stack.length - 1]; + if (frame.index >= frame.children.length) { + active.delete(frame.entry); + ArrayPrototypePop(stack); + continue; } - active.add(entry); - try { - this.#ensurePopulated(entry, currentPath); - - for (const { 0: name, 1: childEntry } of entry.children) { - const childRelative = relativePath ? - relativePath + '/' + name : name; - - if (withFileTypes) { - let type; - if (childEntry.isSymbolicLink()) { - type = UV_DIRENT_LINK; - } else if (childEntry.isDirectory()) { - type = UV_DIRENT_DIR; - } else { - type = UV_DIRENT_FILE; - } - ArrayPrototypePush(results, - new Dirent(childRelative, type, dirPath)); - } else { - ArrayPrototypePush(results, childRelative); - } + const { 0: name, 1: childEntry } = frame.children[frame.index++]; + const childRelative = frame.relativePath ? + frame.relativePath + '/' + name : name; - // Follow symlinks to directories for recursive traversal. - // Track the active traversal path to avoid symlink cycles. - let resolvedChild = childEntry; - if (childEntry.isSymbolicLink()) { - const targetPath = this.#resolveSymlinkTarget( - pathPosix.join(currentPath, name), childEntry.target, - ); - const result = this.#lookupEntry(targetPath, true, 0); - if (result.entry) { - resolvedChild = result.entry; - } - } - if (resolvedChild.isDirectory()) { - const childPath = pathPosix.join(currentPath, name); - walk(resolvedChild, childPath, childRelative); - } + if (withFileTypes) { + let type; + if (childEntry.isSymbolicLink()) { + type = UV_DIRENT_LINK; + } else if (childEntry.isDirectory()) { + type = UV_DIRENT_DIR; + } else { + type = UV_DIRENT_FILE; } - } finally { - active.delete(entry); + ArrayPrototypePush(results, new Dirent(childRelative, type, dirPath)); + } else { + ArrayPrototypePush(results, childRelative); } - }; - walk(dirEntry, dirPath, ''); + // Follow symlinks to directories for recursive traversal, skipping any + // directory already on the active path to avoid symlink cycles. + let resolvedChild = childEntry; + if (childEntry.isSymbolicLink()) { + const targetPath = this.#resolveSymlinkTarget( + pathPosix.join(frame.currentPath, name), childEntry.target, + ); + const result = this.#lookupEntry(targetPath, true, 0); + if (result.entry) { + resolvedChild = result.entry; + } + } + if (resolvedChild.isDirectory() && !active.has(resolvedChild)) { + enter(resolvedChild, pathPosix.join(frame.currentPath, name), + childRelative); + } + } + return results; } diff --git a/src/node_zlib.cc b/src/node_zlib.cc index 95201624cfa2..5a294ba321d2 100644 --- a/src/node_zlib.cc +++ b/src/node_zlib.cc @@ -358,6 +358,7 @@ class ZstdDecompressContext final : public ZstdContext { // Streaming-related, should be available for all compression libraries: void Close(); void DoThreadPoolWork(); + CompressionError GetErrorInfo() const; CompressionError ResetStream(); // Zstd specific: @@ -375,6 +376,7 @@ class ZstdDecompressContext final : public ZstdContext { private: DeleteFnPtr dctx_; + bool frame_complete_ = false; }; class CompressionStreamMemoryOwner { @@ -1717,6 +1719,8 @@ void ZstdDecompressContext::Close() { CompressionError ZstdDecompressContext::Init(uint64_t pledged_src_size, std::string_view dictionary) { + frame_complete_ = false; + #ifdef NODE_BUNDLED_ZSTD ZSTD_customMem custom_mem = { CompressionStreamMemoryOwner::AllocForBrotli, @@ -1752,12 +1756,37 @@ CompressionError ZstdDecompressContext::ResetStream() { } void ZstdDecompressContext::DoThreadPoolWork() { + // The JavaScript processing loop retries with an empty input buffer when the + // previous call filled the output buffer. Avoid interpreting that retry as + // the beginning of a new, incomplete frame. + if (frame_complete_ && input_.size == 0) { + return; + } + size_t const ret = ZSTD_decompressStream(dctx_.get(), &output_, &input_); if (ZSTD_isError(ret)) { + frame_complete_ = false; error_ = ZSTD_getErrorCode(ret); error_code_string_ = ZstdStrerror(error_); error_string_ = ZSTD_getErrorString(error_); + } else { + frame_complete_ = ret == 0; + } +} + +CompressionError ZstdDecompressContext::GetErrorInfo() const { + CompressionError error = ZstdContext::GetErrorInfo(); + if (error.IsError()) { + return error; } + + if (flush_ == ZSTD_e_end && !frame_complete_ && input_.pos == input_.size && + output_.pos < output_.size) { + return CompressionError( + "unexpected end of file", "Z_BUF_ERROR", Z_BUF_ERROR); + } + + return {}; } template diff --git a/src/permission/permission.h b/src/permission/permission.h index 84e3ea67ed5e..6a080fbe73c4 100644 --- a/src/permission/permission.h +++ b/src/permission/permission.h @@ -37,9 +37,11 @@ namespace permission { const auto resource__ = (resource); \ if (!env__->permission()->is_granted(env__, perm__, resource__)) \ [[unlikely]] { \ - node::permission::Permission::ThrowAccessDenied( \ - env__, perm__, resource__); \ - if (!env__->permission()->warning_only()) return __VA_ARGS__; \ + if (!env__->permission()->warning_only()) { \ + node::permission::Permission::ThrowAccessDenied( \ + env__, perm__, resource__); \ + return __VA_ARGS__; \ + } \ } \ } while (0) @@ -51,9 +53,11 @@ namespace permission { const auto resource__ = (resource); \ if (!env__->permission()->is_granted(env__, perm__, resource__)) \ [[unlikely]] { \ - node::permission::Permission::AsyncThrowAccessDenied( \ - env__, (wrap), perm__, resource__); \ - if (!env__->permission()->warning_only()) return __VA_ARGS__; \ + if (!env__->permission()->warning_only()) { \ + node::permission::Permission::AsyncThrowAccessDenied( \ + env__, (wrap), perm__, resource__); \ + return __VA_ARGS__; \ + } \ } \ } while (0) @@ -65,14 +69,17 @@ namespace permission { const auto resource__ = (resource); \ if (!env__->permission()->is_granted(env__, perm__, resource__)) \ [[unlikely]] { \ - Local err_access; \ - if (node::permission::CreateAccessDeniedError(env__, perm__, resource__) \ - .ToLocal(&err_access)) { \ - args.GetReturnValue().Set(err_access); \ - } else { \ - args.GetReturnValue().Set(UV_EACCES); \ + if (!env__->permission()->warning_only()) { \ + Local err_access; \ + if (node::permission::CreateAccessDeniedError( \ + env__, perm__, resource__) \ + .ToLocal(&err_access)) { \ + args.GetReturnValue().Set(err_access); \ + } else { \ + args.GetReturnValue().Set(UV_EACCES); \ + } \ + return __VA_ARGS__; \ } \ - return __VA_ARGS__; \ } \ } while (0) diff --git a/test/parallel/test-permission-audit-fs-does-not-deny.js b/test/parallel/test-permission-audit-fs-does-not-deny.js new file mode 100644 index 000000000000..13284802839d --- /dev/null +++ b/test/parallel/test-permission-audit-fs-does-not-deny.js @@ -0,0 +1,59 @@ +'use strict'; + +const common = require('../common'); +const { isMainThread } = require('worker_threads'); + +if (!isMainThread) { + common.skip('This test only works on a main thread'); +} + +const assert = require('assert'); +const { spawnSync } = require('child_process'); +const { test } = require('node:test'); +const fixtures = require('../common/fixtures'); + +const blockedFile = fixtures.path('permission', 'deny', 'protected-file.md'); + +function runAudit(mode) { + const childScript = ` + const dc = require('node:diagnostics_channel'); + const msgs = []; + dc.subscribe('node:permission-model:fs', (m) => msgs.push({ + permission: m.permission, + resource: m.resource, + })); + try { + ${mode === 'eval' ? + `eval('require("node:fs").readFileSync(process.env.BLOCKED_FILE)');` : + `require('node:fs').readFileSync(process.env.BLOCKED_FILE);`} + console.log('RESULT NO_THROW'); + } catch (e) { + console.log('RESULT THREW ' + e.code); + } + console.log('AUDIT ' + JSON.stringify(msgs)); + `; + + const env = { ...process.env, BLOCKED_FILE: blockedFile }; + const { status, stdout, stderr } = spawnSync( + process.execPath, + ['--permission-audit', '-e', childScript], + { encoding: 'utf8', env }, + ); + assert.strictEqual(status, 0, stderr); + const lines = stdout.split('\n'); + assert.ok(lines.includes('RESULT NO_THROW'), stdout); + const auditLine = lines.find((l) => l.startsWith('AUDIT ')); + assert.ok(auditLine, stdout); + const msgs = JSON.parse(auditLine.replace('AUDIT ', '')); + assert.strictEqual(msgs.length, 1); + assert.strictEqual(msgs[0].permission, 'FileSystemRead'); + assert.ok(msgs[0].resource.endsWith('protected-file.md')); +} + +test('permission-audit logs fs denial without throwing', () => { + runAudit('direct'); +}); + +test('permission-audit logs fs denial without throwing (eval)', () => { + runAudit('eval'); +}); diff --git a/test/parallel/test-permission-audit-net-does-not-deny.js b/test/parallel/test-permission-audit-net-does-not-deny.js new file mode 100644 index 000000000000..4fdaaf3494c9 --- /dev/null +++ b/test/parallel/test-permission-audit-net-does-not-deny.js @@ -0,0 +1,69 @@ +'use strict'; + +const common = require('../common'); +const { isMainThread } = require('worker_threads'); + +if (!isMainThread) { + common.skip('This test only works on a main thread'); +} + +const assert = require('assert'); +const { spawnSync } = require('child_process'); +const { test } = require('node:test'); +const net = require('net'); + +async function runAudit(mode) { + const server = net.createServer(); + + await new Promise((resolve, reject) => { + server.on('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const { port } = server.address(); + const env = { ...process.env, PORT: String(port), HOST: '127.0.0.1' }; + + const childScript = ` + const dc = require('node:diagnostics_channel'); + const net = require('node:net'); + const msgs = []; + dc.subscribe('node:permission-model:net', (m) => msgs.push({ + permission: m.permission, + resource: m.resource, + })); + const s = ${mode === 'eval' ? + `eval('net.connect(Number(process.env.PORT), process.env.HOST)')` : + `net.connect(Number(process.env.PORT), process.env.HOST)`}; + s.on('connect', () => { s.destroy(); console.log('RESULT CONNECTED'); }); + s.on('error', (e) => { console.log('RESULT ERROR ' + e.code); }); + s.on('close', () => { + console.log('AUDIT ' + JSON.stringify(msgs)); + }); + `; + + try { + const { status, stdout, stderr } = spawnSync( + process.execPath, + ['--permission-audit', '-e', childScript], + { encoding: 'utf8', env }, + ); + assert.strictEqual(status, 0, stderr); + const lines = stdout.split('\n'); + assert.strictEqual(lines[0], 'RESULT CONNECTED', stdout); + const auditLine = lines.find((l) => l.startsWith('AUDIT ')); + assert.ok(auditLine, stdout); + const msgs = JSON.parse(auditLine.replace('AUDIT ', '')); + assert.strictEqual(msgs.length, 1); + assert.strictEqual(msgs[0].permission, 'Net'); + assert.ok(msgs[0].resource.includes('127.0.0.1')); + } finally { + server.close(); + } +} + +test('permission-audit logs net denial without blocking connect', async () => { + await runAudit('direct'); +}); + +test('permission-audit logs net denial without blocking connect (eval)', async () => { + await runAudit('eval'); +}); diff --git a/test/parallel/test-vfs-readdir-symlink-recursive.js b/test/parallel/test-vfs-readdir-symlink-recursive.js index 33b52bb5d4a4..f46a12da7b9a 100644 --- a/test/parallel/test-vfs-readdir-symlink-recursive.js +++ b/test/parallel/test-vfs-readdir-symlink-recursive.js @@ -105,3 +105,32 @@ assert.ok( assert.ok(dirents.some((d) => d.name === 'sub' && d.isDirectory())); assert.ok(dirents.some((d) => d.name === 'lnk' && d.isSymbolicLink())); } + +// Recursive readdir on a deeply nested tree must not exhaust the call stack. +// The iterative traversal introduced in this fix handles arbitrarily deep trees +// without recursion, so this should complete without a RangeError. +{ + const DEPTH = 1000; + const v = vfs.create(); + + // Build /deep/0/1/2/.../999/leaf.txt + let path = '/deep'; + v.mkdirSync(path); + for (let i = 0; i < DEPTH; i++) { + path += `/${i}`; + v.mkdirSync(path); + } + v.writeFileSync(`${path}/leaf.txt`, 'deep'); + + const entries = v.readdirSync('/deep', { recursive: true }); + + // Every intermediate directory and the leaf file must appear. + assert.strictEqual(entries.length, DEPTH + 1); + + // Build the expected relative path to the leaf file. + const expectedLeaf = Array.from({ length: DEPTH }, (_, i) => i).join('/') + '/leaf.txt'; + assert.ok( + entries.includes(expectedLeaf), + `Expected '${expectedLeaf}' in deep-tree entries`, + ); +} diff --git a/test/parallel/test-zlib-truncated.js b/test/parallel/test-zlib-truncated.js index c489388a674e..0f9ce776b1db 100644 --- a/test/parallel/test-zlib-truncated.js +++ b/test/parallel/test-zlib-truncated.js @@ -22,6 +22,12 @@ const errMessage = /unexpected end of file/; { comp: 'gzip', decomp: 'unzip', decompSync: 'unzipSync' }, { comp: 'deflate', decomp: 'inflate', decompSync: 'inflateSync' }, { comp: 'deflateRaw', decomp: 'inflateRaw', decompSync: 'inflateRawSync' }, + { + comp: 'zstdCompress', + decomp: 'zstdDecompress', + decompSync: 'zstdDecompressSync', + partialFlush: zlib.constants.ZSTD_e_flush, + }, ].forEach(function(methods) { zlib[methods.comp](inputString, common.mustSucceed((compressed) => { const truncated = compressed.slice(0, compressed.length / 2); @@ -46,16 +52,59 @@ const errMessage = /unexpected end of file/; assert.match(err.message, errMessage); })); - const syncFlushOpt = { finishFlush: zlib.constants.Z_SYNC_FLUSH }; + const partialFlushOpt = { + finishFlush: methods.partialFlush ?? zlib.constants.Z_SYNC_FLUSH, + }; - // Sync truncated input test, finishFlush = Z_SYNC_FLUSH - const result = toUTF8(zlib[methods.decompSync](truncated, syncFlushOpt)); + // Sync truncated input test with a non-finalizing finish flush. + const result = toUTF8(zlib[methods.decompSync](truncated, partialFlushOpt)); assert.strictEqual(result, inputString.slice(0, result.length)); - // Async truncated input test, finishFlush = Z_SYNC_FLUSH - zlib[methods.decomp](truncated, syncFlushOpt, common.mustSucceed((decompressed) => { + // Async truncated input test with a non-finalizing finish flush. + zlib[methods.decomp](truncated, partialFlushOpt, common.mustSucceed((decompressed) => { const result = toUTF8(decompressed); assert.strictEqual(result, inputString.slice(0, result.length)); })); })); }); + +// A non-zero return from ZSTD_decompressStream() can also mean that the +// output buffer is full. Make sure that is drained before treating the return +// value as truncated input. +{ + const input = Buffer.alloc(zlib.constants.Z_DEFAULT_CHUNK * 2, 0x61); + const compressed = zlib.zstdCompressSync(input); + const decompressed = zlib.zstdDecompressSync(compressed, { + chunkSize: zlib.constants.Z_MIN_CHUNK, + }); + assert.deepStrictEqual(decompressed, input); +} + +// Ending a stream after a previous write completed a frame must not be +// mistaken for an empty, truncated frame. +{ + const input = Buffer.from(inputString); + const compressed = zlib.zstdCompressSync(input); + const decompressor = zlib.createZstdDecompress(); + const output = []; + + decompressor.on('data', (chunk) => output.push(chunk)); + decompressor.on('end', common.mustCall(() => { + assert.deepStrictEqual(Buffer.concat(output), input); + })); + decompressor.write(compressed, common.mustCall(() => decompressor.end())); +} + +// Conversely, ending after a previous write supplied only part of a frame +// must report that the frame is incomplete. +{ + const compressed = zlib.zstdCompressSync(inputString); + const truncated = compressed.subarray(0, compressed.length / 2); + const decompressor = zlib.createZstdDecompress(); + + decompressor.on('error', common.mustCall((error) => { + assert.match(error.message, errMessage); + })); + decompressor.write(truncated, common.mustCall(() => decompressor.end())); + decompressor.resume(); +} diff --git a/tools/nix/pkgs-26.05.nix b/tools/nix/pkgs-26.05.nix index c87a0cad2262..e0697c7a99b9 100644 --- a/tools/nix/pkgs-26.05.nix +++ b/tools/nix/pkgs-26.05.nix @@ -1,10 +1,10 @@ arg: let repo = "https://github.com/NixOS/nixpkgs"; - rev = "572a2c2b6faebd71246e3162e4217d7ca63a9300"; + rev = "fc51889f81924f15fba77a3c0b79cfb3f78fe0d4"; nixpkgs = import (builtins.fetchTarball { url = "${repo}/archive/${rev}.tar.gz"; - sha256 = "0c5xyqgip1kf7hinqbmfvsf8c7jwipbyj7dlb337gd058kz7zmwm"; + sha256 = "16397a8zmfj9gygm0yj4fhp9sj6hw954k246jyzsi0xpgwdzr76h"; }) arg; in # Unstable channel no longer supports Intel architecture for macOS. We can use the 26.05 channel diff --git a/tools/nix/pkgs.nix b/tools/nix/pkgs.nix index 80954b54ad42..54fde3a51b80 100644 --- a/tools/nix/pkgs.nix +++ b/tools/nix/pkgs.nix @@ -1,10 +1,10 @@ arg: let repo = "https://github.com/NixOS/nixpkgs"; - rev = "2065d53daf2c81ed7b57947e2e56682ded62723a"; + rev = "20535e48e12c86043b577b8518234ff5dbb26957"; nixpkgs = import (builtins.fetchTarball { url = "${repo}/archive/${rev}.tar.gz"; - sha256 = "0ib5k850qp6zgzzvkbkpv0iwy649hwd8ab6id5w2jq4jplnf6zr3"; + sha256 = "1dmdschkpmhjp67rhsig7k2qhgd918j5g30s6yxmjljqsxh2vlh9"; }) arg; in # Unstable channel no longer supports Intel architecture for macOS. We can use the 26.05 channel