From 10cfcbeaf75f92542e98bc6df5756018d58ab6c8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 5 Mar 2026 17:34:11 +0000 Subject: [PATCH 1/2] tools: bump the eslint group across 1 directory with 2 updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the eslint group with 2 updates in the /tools/eslint directory: [eslint](https://github.com/eslint/eslint) and [eslint-plugin-jsdoc](https://github.com/gajus/eslint-plugin-jsdoc). Updates `eslint` from 10.0.1 to 10.0.2 - [Release notes](https://github.com/eslint/eslint/releases) - [Commits](https://github.com/eslint/eslint/compare/v10.0.1...v10.0.2) Updates `eslint-plugin-jsdoc` from 62.7.0 to 62.7.1 - [Release notes](https://github.com/gajus/eslint-plugin-jsdoc/releases) - [Commits](https://github.com/gajus/eslint-plugin-jsdoc/compare/v62.7.0...v62.7.1) --- updated-dependencies: - dependency-name: eslint dependency-version: 10.0.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: eslint - dependency-name: eslint-plugin-jsdoc dependency-version: 62.7.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: eslint ... Signed-off-by: dependabot[bot] PR-URL: https://github.com/nodejs/node/pull/62092 Reviewed-By: Michaël Zasso Reviewed-By: Colin Ihrig Reviewed-By: Luigi Pinca --- tools/eslint/package-lock.json | 22 +++++++++------------- tools/eslint/package.json | 4 ++-- 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/tools/eslint/package-lock.json b/tools/eslint/package-lock.json index 515dfae3335769..b8a33ce4881db3 100644 --- a/tools/eslint/package-lock.json +++ b/tools/eslint/package-lock.json @@ -14,9 +14,9 @@ "@eslint/js": "^10.0.1", "@eslint/markdown": "^7.5.1", "@stylistic/eslint-plugin": "^5.9.0", - "eslint": "^10.0.1", + "eslint": "^10.0.2", "eslint-formatter-tap": "^9.0.1", - "eslint-plugin-jsdoc": "^62.7.0", + "eslint-plugin-jsdoc": "^62.7.1", "globals": "^17.3.0" } }, @@ -47,7 +47,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-8.0.0-rc.2.tgz", "integrity": "sha512-mlBJdKJJEZNGDE+w+P6B5w+FTMkht1liPkxtB4wk39EpGH01Am5tg1htaNlOU5rO9Ge3psMjAFycpc3ru5uaQw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^8.0.0-rc.2", "@babel/generator": "^8.0.0-rc.2", @@ -663,7 +662,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -763,7 +761,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -933,11 +930,10 @@ } }, "node_modules/eslint": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.1.tgz", - "integrity": "sha512-20MV9SUdeN6Jd84xESsKhRly+/vxI+hwvpBMA93s+9dAcjdCuCojn4IqUGS3lvVaqjVYGYHSRMCpeFtF2rQYxQ==", + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.2.tgz", + "integrity": "sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw==", "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -949,7 +945,7 @@ "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "ajv": "^6.12.4", + "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", @@ -1001,9 +997,9 @@ } }, "node_modules/eslint-plugin-jsdoc": { - "version": "62.7.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-62.7.0.tgz", - "integrity": "sha512-jootujJOIGMkCLN+/WgDFKtaclCt2MEEy9cZ1RyK19Az1JvVI3awbeMXNlJ6y4h8RWIJpcXqmxsu4t9NThYbNw==", + "version": "62.7.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-62.7.1.tgz", + "integrity": "sha512-4Zvx99Q7d1uggYBUX/AIjvoyqXhluGbbKrRmG8SQTLprPFg6fa293tVJH1o1GQwNe3lUydd8ZHzn37OaSncgSQ==", "license": "BSD-3-Clause", "dependencies": { "@es-joy/jsdoccomment": "~0.84.0", diff --git a/tools/eslint/package.json b/tools/eslint/package.json index 72de28a5bb8080..6225ad5486c26f 100644 --- a/tools/eslint/package.json +++ b/tools/eslint/package.json @@ -9,9 +9,9 @@ "@eslint/js": "^10.0.1", "@eslint/markdown": "^7.5.1", "@stylistic/eslint-plugin": "^5.9.0", - "eslint": "^10.0.1", + "eslint": "^10.0.2", "eslint-formatter-tap": "^9.0.1", - "eslint-plugin-jsdoc": "^62.7.0", + "eslint-plugin-jsdoc": "^62.7.1", "globals": "^17.3.0" } } From 8edeff9aa715bbc23f22a68bd864eba1854a9513 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Thu, 5 Mar 2026 20:32:54 +0100 Subject: [PATCH 2/2] stream: fix UTF-8 character corruption in fast-utf8-stream Fix releaseWritingBuf() to correctly handle partial writes that split multi-byte UTF-8 characters. The previous implementation incorrectly converted byte counts to character counts, causing: - 3-byte characters (CJK) to be silently dropped - 4-byte characters (emoji) to leave lone surrogates in the buffer The fix backs up from the byte position to find a valid UTF-8 character boundary by checking for continuation bytes (pattern 10xxxxxx), then decodes the properly-aligned bytes to get the correct character count. Also fixes a typo where this._asyncDrainScheduled was used instead of the private field this.#asyncDrainScheduled. Fixes: https://github.com/nodejs/node/issues/61744 PR-URL: https://github.com/nodejs/node/pull/61745 Reviewed-By: Yagiz Nizipli Reviewed-By: James M Snell Reviewed-By: Colin Ihrig Reviewed-By: Paolo Insogna --- lib/internal/streams/fast-utf8-stream.js | 24 +- .../test-fastutf8stream-partial-write-utf8.js | 322 ++++++++++++++++++ 2 files changed, 340 insertions(+), 6 deletions(-) create mode 100644 test/parallel/test-fastutf8stream-partial-write-utf8.js diff --git a/lib/internal/streams/fast-utf8-stream.js b/lib/internal/streams/fast-utf8-stream.js index 25f4771d052a2a..cb86f245302620 100644 --- a/lib/internal/streams/fast-utf8-stream.js +++ b/lib/internal/streams/fast-utf8-stream.js @@ -237,7 +237,7 @@ class Utf8Stream extends EventEmitter { this.on('newListener', (name) => { if (name === 'drain') { - this._asyncDrainScheduled = false; + this.#asyncDrainScheduled = false; } }); @@ -894,11 +894,23 @@ class Utf8Stream extends EventEmitter { * @returns {{writingBuf: string | Buffer, len: number}} released writingBuf and length */ function releaseWritingBuf(writingBuf, len, n) { - // if Buffer.byteLength is equal to n, that means writingBuf contains no multi-byte character - if (typeof writingBuf === 'string' && Buffer.byteLength(writingBuf) !== n) { - // Since the fs.write callback parameter `n` means how many bytes the passed of string - // We calculate the original string length for avoiding the multi-byte character issue - n = Buffer.from(writingBuf).subarray(0, n).toString().length; + if (typeof writingBuf === 'string') { + const byteLength = Buffer.byteLength(writingBuf); + if (byteLength !== n) { + // Since fs.write returns the number of bytes written, we need to find + // how many complete characters fit within those n bytes. + // If a partial write splits a multi-byte UTF-8 character, we must back up + // to the start of that character to avoid data corruption. + const buf = Buffer.from(writingBuf); + // Back up from position n to find a valid UTF-8 character boundary. + // UTF-8 continuation bytes have the pattern 10xxxxxx (0x80-0xBF). + // We need to find the start of the character that was split. + while (n > 0 && (buf[n] & 0xC0) === 0x80) { + n--; + } + // Decode the properly-aligned bytes to get the character count. + n = buf.subarray(0, n).toString().length; + } } len = MathMax(len - n, 0); writingBuf = writingBuf.slice(n); diff --git a/test/parallel/test-fastutf8stream-partial-write-utf8.js b/test/parallel/test-fastutf8stream-partial-write-utf8.js new file mode 100644 index 00000000000000..3a56fcf71a7b44 --- /dev/null +++ b/test/parallel/test-fastutf8stream-partial-write-utf8.js @@ -0,0 +1,322 @@ +'use strict'; + +// Tests for UTF-8 character preservation when partial writes split multi-byte characters. +// See: https://github.com/nodejs/node/issues/61744 + +const common = require('../common'); +const tmpdir = require('../common/tmpdir'); +const assert = require('node:assert'); +const { + openSync, + write, + writeSync, +} = require('node:fs'); +const { Utf8Stream } = require('node:fs'); +const { join } = require('node:path'); +const { isMainThread } = require('node:worker_threads'); + +tmpdir.refresh(); +if (isMainThread) { + process.umask(0o000); +} + +let fileCounter = 0; + +function getTempFile() { + return join(tmpdir.path, `fastutf8stream-partial-${process.pid}-${Date.now()}-${fileCounter++}.log`); +} + +runTests(false); +runTests(true); + +function runTests(sync) { + // Test 1: Partial write splitting a 3-byte UTF-8 character (CJK) + // "abc中def" where "中" is 3 bytes (E4 B8 AD) + // Simulate partial write of 4 bytes: "abc" (3 bytes) + first byte of "中" + // The remaining buffer should be "中def" (not "def") + { + const dest = getTempFile(); + const fd = openSync(dest, 'w'); + + let firstWrite = true; + const writtenChunks = []; + const fsOverride = {}; + + if (sync) { + fsOverride.writeSync = common.mustCall((...args) => { + const data = args[1]; + writtenChunks.push(typeof data === 'string' ? data : data.toString()); + if (firstWrite) { + firstWrite = false; + // Simulate partial write: only 4 bytes written out of 9 + // This splits the 3-byte "中" character + return 4; + } + return writeSync(...args); + }, 2); + } else { + fsOverride.write = common.mustCall((...args) => { + const data = args[1]; + writtenChunks.push(typeof data === 'string' ? data : data.toString()); + const callback = args[args.length - 1]; + if (firstWrite) { + firstWrite = false; + // Simulate partial write: only 4 bytes written out of 9 + process.nextTick(callback, null, 4); + return; + } + return write(...args); + }, 2); + } + + const stream = new Utf8Stream({ + fd, + sync, + minLength: 0, + fs: fsOverride, + }); + + stream.on('ready', common.mustCall(() => { + stream.write('abc中def'); + stream.end(); + + stream.on('finish', common.mustCall(() => { + // Verify the second chunk contains the preserved CJK character + assert.strictEqual(writtenChunks.length, 2); + assert.strictEqual(writtenChunks[0], 'abc中def'); // First attempt + assert.strictEqual(writtenChunks[1], '中def'); // Retry with preserved char + })); + })); + } + + // Test 2: Partial write splitting a 4-byte UTF-8 character (emoji) + // "hello🌍world" where "🌍" is 4 bytes (F0 9F 8C 8D) + // Simulate partial write of 7 bytes: "hello" (5 bytes) + first 2 bytes of "🌍" + // The remaining buffer should be "🌍world" (not a lone surrogate + "world") + { + const dest = getTempFile(); + const fd = openSync(dest, 'w'); + + let firstWrite = true; + const writtenChunks = []; + const fsOverride = {}; + + if (sync) { + fsOverride.writeSync = common.mustCall((...args) => { + const data = args[1]; + writtenChunks.push(typeof data === 'string' ? data : data.toString()); + if (firstWrite) { + firstWrite = false; + // Simulate partial write: only 7 bytes written + return 7; + } + return writeSync(...args); + }, 2); + } else { + fsOverride.write = common.mustCall((...args) => { + const data = args[1]; + writtenChunks.push(typeof data === 'string' ? data : data.toString()); + const callback = args[args.length - 1]; + if (firstWrite) { + firstWrite = false; + process.nextTick(callback, null, 7); + return; + } + return write(...args); + }, 2); + } + + const stream = new Utf8Stream({ + fd, + sync, + minLength: 0, + fs: fsOverride, + }); + + stream.on('ready', common.mustCall(() => { + stream.write('hello🌍world'); + stream.end(); + + stream.on('finish', common.mustCall(() => { + assert.strictEqual(writtenChunks.length, 2); + assert.strictEqual(writtenChunks[0], 'hello🌍world'); // First attempt + assert.strictEqual(writtenChunks[1], '🌍world'); // Retry with preserved emoji + + // Verify no lone surrogates in the retry chunk + const retryChunk = writtenChunks[1]; + for (let i = 0; i < retryChunk.length; i++) { + const code = retryChunk.charCodeAt(i); + if (code >= 0xD800 && code <= 0xDBFF) { + // High surrogate - next must be low surrogate + const next = retryChunk.charCodeAt(i + 1); + assert.ok(next >= 0xDC00 && next <= 0xDFFF, + `Found lone high surrogate at position ${i}`); + i++; // Skip the low surrogate we just verified + } else if (code >= 0xDC00 && code <= 0xDFFF) { + // Low surrogate without preceding high surrogate + assert.fail(`Found lone low surrogate at position ${i}: 0x${code.toString(16)}`); + } + } + })); + })); + } + + // Test 3: Partial write at exactly 0 bytes (edge case) + { + const dest = getTempFile(); + const fd = openSync(dest, 'w'); + + let firstWrite = true; + const writtenChunks = []; + const fsOverride = {}; + + if (sync) { + fsOverride.writeSync = common.mustCall((...args) => { + const data = args[1]; + writtenChunks.push(typeof data === 'string' ? data : data.toString()); + if (firstWrite) { + firstWrite = false; + return 0; // No bytes written + } + return writeSync(...args); + }, 2); + } else { + fsOverride.write = common.mustCall((...args) => { + const data = args[1]; + writtenChunks.push(typeof data === 'string' ? data : data.toString()); + const callback = args[args.length - 1]; + if (firstWrite) { + firstWrite = false; + process.nextTick(callback, null, 0); + return; + } + return write(...args); + }, 2); + } + + const stream = new Utf8Stream({ + fd, + sync, + minLength: 0, + fs: fsOverride, + }); + + stream.on('ready', common.mustCall(() => { + stream.write('中文'); + stream.end(); + + stream.on('finish', common.mustCall(() => { + assert.strictEqual(writtenChunks.length, 2); + assert.strictEqual(writtenChunks[0], '中文'); + assert.strictEqual(writtenChunks[1], '中文'); // Entire string retried + })); + })); + } + + // Test 4: Partial write splitting between characters (not mid-character) + // This should work the same as before - no character preservation needed + { + const dest = getTempFile(); + const fd = openSync(dest, 'w'); + + let firstWrite = true; + const writtenChunks = []; + const fsOverride = {}; + + if (sync) { + fsOverride.writeSync = common.mustCall((...args) => { + const data = args[1]; + writtenChunks.push(typeof data === 'string' ? data : data.toString()); + if (firstWrite) { + firstWrite = false; + // Write exactly 3 bytes ("abc"), which is a clean character boundary + return 3; + } + return writeSync(...args); + }, 2); + } else { + fsOverride.write = common.mustCall((...args) => { + const data = args[1]; + writtenChunks.push(typeof data === 'string' ? data : data.toString()); + const callback = args[args.length - 1]; + if (firstWrite) { + firstWrite = false; + process.nextTick(callback, null, 3); + return; + } + return write(...args); + }, 2); + } + + const stream = new Utf8Stream({ + fd, + sync, + minLength: 0, + fs: fsOverride, + }); + + stream.on('ready', common.mustCall(() => { + stream.write('abc中def'); + stream.end(); + + stream.on('finish', common.mustCall(() => { + assert.strictEqual(writtenChunks.length, 2); + assert.strictEqual(writtenChunks[0], 'abc中def'); + assert.strictEqual(writtenChunks[1], '中def'); // Remaining after 3 bytes + })); + })); + } + + // Test 5: Single multi-byte character with partial write of 1 byte + { + const dest = getTempFile(); + const fd = openSync(dest, 'w'); + + let firstWrite = true; + const writtenChunks = []; + const fsOverride = {}; + + if (sync) { + fsOverride.writeSync = common.mustCall((...args) => { + const data = args[1]; + writtenChunks.push(typeof data === 'string' ? data : data.toString()); + if (firstWrite) { + firstWrite = false; + // Write only 1 byte of a 3-byte character + return 1; + } + return writeSync(...args); + }, 2); + } else { + fsOverride.write = common.mustCall((...args) => { + const data = args[1]; + writtenChunks.push(typeof data === 'string' ? data : data.toString()); + const callback = args[args.length - 1]; + if (firstWrite) { + firstWrite = false; + process.nextTick(callback, null, 1); + return; + } + return write(...args); + }, 2); + } + + const stream = new Utf8Stream({ + fd, + sync, + minLength: 0, + fs: fsOverride, + }); + + stream.on('ready', common.mustCall(() => { + stream.write('中'); + stream.end(); + + stream.on('finish', common.mustCall(() => { + assert.strictEqual(writtenChunks.length, 2); + assert.strictEqual(writtenChunks[0], '中'); + assert.strictEqual(writtenChunks[1], '中'); // Full character retried + })); + })); + } +}