From 7c88d730de50f2574df801a8fda710a5ec49eb0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Afonso=20Janu=C3=A1rio?= Date: Sun, 6 Sep 2026 01:14:47 +0100 Subject: [PATCH 1/3] Treat Unicode-normalization-only renames like case-only renames Moving a file to a name that differs only in Unicode normalization form (NFC vs NFD) throws "Source and destination must not be the same." on filesystems that auto-normalize, such as APFS, even though the source and destination are visually identical and the user is just renaming the file. This mirrors the case-insensitive rename bug fixed in #801, so it gets the same fix: when the basenames differ but resolve to the same inode, and they're equal after either lowercasing or NFC normalization, treat it as a rename instead of raising the same-path error. Fixes #859 --- ...e-sync-unicode-normalization-paths.test.js | 50 +++++++++++++++++ .../move-unicode-normalization-paths.test.js | 56 +++++++++++++++++++ lib/util/stat.js | 15 ++++- 3 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 lib/move/__tests__/move-sync-unicode-normalization-paths.test.js create mode 100644 lib/move/__tests__/move-unicode-normalization-paths.test.js diff --git a/lib/move/__tests__/move-sync-unicode-normalization-paths.test.js b/lib/move/__tests__/move-sync-unicode-normalization-paths.test.js new file mode 100644 index 00000000..0d91671a --- /dev/null +++ b/lib/move/__tests__/move-sync-unicode-normalization-paths.test.js @@ -0,0 +1,50 @@ +'use strict' + +const assert = require('assert') +const os = require('os') +const path = require('path') +const fs = require('../../') + +/* global beforeEach, afterEach, describe, it */ + +describe('+ moveSync() - unicode normalization paths', () => { + let TEST_DIR = '' + let src = '' + let dest = '' + + // 'é' as one precomposed codepoint (NFC) vs 'e' + combining acute accent (NFD). + // Visually identical, different byte representation. + const nfc = 'café'.normalize('NFC') + const nfd = 'café'.normalize('NFD') + + beforeEach(done => { + TEST_DIR = path.join(os.tmpdir(), 'fs-extra', 'move-sync-unicode-normalization-paths') + fs.emptyDir(TEST_DIR, done) + }) + + afterEach(() => fs.removeSync(TEST_DIR)) + + describe('> when src is a directory', () => { + it('should move successfully', () => { + src = path.join(TEST_DIR, `${nfd}-dir`) + fs.outputFileSync(path.join(src, 'subdir', 'file.txt'), 'some data') + dest = path.join(TEST_DIR, `${nfc}-dir`) + + fs.moveSync(src, dest) + assert(fs.existsSync(dest)) + assert.strictEqual(fs.readFileSync(path.join(dest, 'subdir', 'file.txt'), 'utf8'), 'some data') + }) + }) + + describe('> when src is a file', () => { + it('should move successfully', () => { + src = path.join(TEST_DIR, `${nfd}-file`) + fs.outputFileSync(src, 'some data') + dest = path.join(TEST_DIR, `${nfc}-file`) + + fs.moveSync(src, dest) + assert(fs.existsSync(dest)) + assert.strictEqual(fs.readFileSync(dest, 'utf8'), 'some data') + }) + }) +}) diff --git a/lib/move/__tests__/move-unicode-normalization-paths.test.js b/lib/move/__tests__/move-unicode-normalization-paths.test.js new file mode 100644 index 00000000..a01407bf --- /dev/null +++ b/lib/move/__tests__/move-unicode-normalization-paths.test.js @@ -0,0 +1,56 @@ +'use strict' + +const assert = require('assert') +const os = require('os') +const path = require('path') +const fs = require('../../') + +/* global beforeEach, afterEach, describe, it */ + +describe('+ move() - unicode normalization paths', () => { + let TEST_DIR = '' + let src = '' + let dest = '' + + // 'é' as one precomposed codepoint (NFC) vs 'e' + combining acute accent (NFD). + // Visually identical, different byte representation. + const nfc = 'café'.normalize('NFC') + const nfd = 'café'.normalize('NFD') + + beforeEach(done => { + TEST_DIR = path.join(os.tmpdir(), 'fs-extra', 'move-unicode-normalization-paths') + fs.emptyDir(TEST_DIR, done) + }) + + afterEach(() => fs.removeSync(TEST_DIR)) + + describe('> when src is a directory', () => { + it('should move successfully', done => { + src = path.join(TEST_DIR, `${nfd}-dir`) + fs.outputFileSync(path.join(src, 'subdir', 'file.txt'), 'some data') + dest = path.join(TEST_DIR, `${nfc}-dir`) + + fs.move(src, dest, err => { + assert.ifError(err) + assert(fs.existsSync(dest)) + assert.strictEqual(fs.readFileSync(path.join(dest, 'subdir', 'file.txt'), 'utf8'), 'some data') + done() + }) + }) + }) + + describe('> when src is a file', () => { + it('should move successfully', done => { + src = path.join(TEST_DIR, `${nfd}-file`) + fs.outputFileSync(src, 'some data') + dest = path.join(TEST_DIR, `${nfc}-file`) + + fs.move(src, dest, err => { + assert.ifError(err) + assert(fs.existsSync(dest)) + assert.strictEqual(fs.readFileSync(dest, 'utf8'), 'some data') + done() + }) + }) + }) +}) diff --git a/lib/util/stat.js b/lib/util/stat.js index 6afc894e..453e9873 100644 --- a/lib/util/stat.js +++ b/lib/util/stat.js @@ -40,7 +40,7 @@ async function checkPaths (src, dest, funcName, opts) { const destBaseName = path.basename(dest) if (funcName === 'move' && srcBaseName !== destBaseName && - srcBaseName.toLowerCase() === destBaseName.toLowerCase()) { + isCosmeticRename(srcBaseName, destBaseName)) { return { srcStat, destStat, isChangingCase: true } } throw new Error('Source and destination must not be the same.') @@ -69,7 +69,7 @@ function checkPathsSync (src, dest, funcName, opts) { const destBaseName = path.basename(dest) if (funcName === 'move' && srcBaseName !== destBaseName && - srcBaseName.toLowerCase() === destBaseName.toLowerCase()) { + isCosmeticRename(srcBaseName, destBaseName)) { return { srcStat, destStat, isChangingCase: true } } throw new Error('Source and destination must not be the same.') @@ -88,6 +88,17 @@ function checkPathsSync (src, dest, funcName, opts) { return { srcStat, destStat } } +// True when src and dest refer to the same inode and their basenames only +// differ in letter case or Unicode normalization form. Some filesystems +// (APFS, most Windows filesystems) silently normalize case and/or Unicode +// representation, so a rename that looks like a no-op string-wise is +// actually the user renaming a file to a visually-identical but +// differently-encoded name, not a genuine "same path" error. +function isCosmeticRename (srcBaseName, destBaseName) { + return srcBaseName.toLowerCase() === destBaseName.toLowerCase() || + srcBaseName.normalize('NFC') === destBaseName.normalize('NFC') +} + // recursively check if dest parent is a subdirectory of src. // It works for all file types including symlinks since it // checks the src and dest inodes. It starts from the deepest From 7088c1d1fbbc7a2a5ceb0695ccfddacd058ffd50 Mon Sep 17 00:00:00 2001 From: Ryan Zimmerman Date: Thu, 17 Sep 2026 11:15:09 -0400 Subject: [PATCH 2/3] Add tests for capitalization change at the same time --- ...e-sync-unicode-normalization-paths.test.js | 27 ++++++++++++++++ .../move-unicode-normalization-paths.test.js | 32 +++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/lib/move/__tests__/move-sync-unicode-normalization-paths.test.js b/lib/move/__tests__/move-sync-unicode-normalization-paths.test.js index 0d91671a..38490f6b 100644 --- a/lib/move/__tests__/move-sync-unicode-normalization-paths.test.js +++ b/lib/move/__tests__/move-sync-unicode-normalization-paths.test.js @@ -16,6 +16,7 @@ describe('+ moveSync() - unicode normalization paths', () => { // Visually identical, different byte representation. const nfc = 'café'.normalize('NFC') const nfd = 'café'.normalize('NFD') + const nfcCapitalized = 'Café'.normalize('NFC') beforeEach(done => { TEST_DIR = path.join(os.tmpdir(), 'fs-extra', 'move-sync-unicode-normalization-paths') @@ -47,4 +48,30 @@ describe('+ moveSync() - unicode normalization paths', () => { assert.strictEqual(fs.readFileSync(dest, 'utf8'), 'some data') }) }) + + describe("when there's also a capitalization change", () => { + describe('> when src is a directory', () => { + it('should move successfully', () => { + src = path.join(TEST_DIR, `${nfd}-dir`) + fs.outputFileSync(path.join(src, 'subdir', 'file.txt'), 'some data') + dest = path.join(TEST_DIR, `${nfcCapitalized}-dir`) + + fs.moveSync(src, dest) + assert(fs.existsSync(dest)) + assert.strictEqual(fs.readFileSync(path.join(dest, 'subdir', 'file.txt'), 'utf8'), 'some data') + }) + }) + + describe('> when src is a file', () => { + it('should move successfully', () => { + src = path.join(TEST_DIR, `${nfd}-file`) + fs.outputFileSync(src, 'some data') + dest = path.join(TEST_DIR, `${nfcCapitalized}-file`) + + fs.moveSync(src, dest) + assert(fs.existsSync(dest)) + assert.strictEqual(fs.readFileSync(dest, 'utf8'), 'some data') + }) + }) + }) }) diff --git a/lib/move/__tests__/move-unicode-normalization-paths.test.js b/lib/move/__tests__/move-unicode-normalization-paths.test.js index a01407bf..5a57cd9f 100644 --- a/lib/move/__tests__/move-unicode-normalization-paths.test.js +++ b/lib/move/__tests__/move-unicode-normalization-paths.test.js @@ -16,6 +16,7 @@ describe('+ move() - unicode normalization paths', () => { // Visually identical, different byte representation. const nfc = 'café'.normalize('NFC') const nfd = 'café'.normalize('NFD') + const nfcCapitalized = 'Café'.normalize('NFC') beforeEach(done => { TEST_DIR = path.join(os.tmpdir(), 'fs-extra', 'move-unicode-normalization-paths') @@ -53,4 +54,35 @@ describe('+ move() - unicode normalization paths', () => { }) }) }) + describe("when there's also a capitalization change", () => { + describe('> when src is a directory', () => { + it('should move successfully', done => { + src = path.join(TEST_DIR, `${nfd}-dir`) + fs.outputFileSync(path.join(src, 'subdir', 'file.txt'), 'some data') + dest = path.join(TEST_DIR, `${nfcCapitalized}-dir`) + + fs.move(src, dest, err => { + assert.ifError(err) + assert(fs.existsSync(dest)) + assert.strictEqual(fs.readFileSync(path.join(dest, 'subdir', 'file.txt'), 'utf8'), 'some data') + done() + }) + }) + }) + + describe('> when src is a file', () => { + it('should move successfully', done => { + src = path.join(TEST_DIR, `${nfd}-file`) + fs.outputFileSync(src, 'some data') + dest = path.join(TEST_DIR, `${nfcCapitalized}-file`) + + fs.move(src, dest, err => { + assert.ifError(err) + assert(fs.existsSync(dest)) + assert.strictEqual(fs.readFileSync(dest, 'utf8'), 'some data') + done() + }) + }) + }) + }) }) From d8607fd5d6387cfc93518190a983afd22c5810ca Mon Sep 17 00:00:00 2001 From: Ryan Zimmerman Date: Thu, 17 Sep 2026 11:34:19 -0400 Subject: [PATCH 3/3] Normalize both case and unicode forms together when comparing --- lib/util/stat.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/util/stat.js b/lib/util/stat.js index 453e9873..040d14c7 100644 --- a/lib/util/stat.js +++ b/lib/util/stat.js @@ -95,8 +95,7 @@ function checkPathsSync (src, dest, funcName, opts) { // actually the user renaming a file to a visually-identical but // differently-encoded name, not a genuine "same path" error. function isCosmeticRename (srcBaseName, destBaseName) { - return srcBaseName.toLowerCase() === destBaseName.toLowerCase() || - srcBaseName.normalize('NFC') === destBaseName.normalize('NFC') + return srcBaseName.toLowerCase().normalize('NFC') === destBaseName.toLowerCase().normalize('NFC') } // recursively check if dest parent is a subdirectory of src.