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..38490f6b --- /dev/null +++ b/lib/move/__tests__/move-sync-unicode-normalization-paths.test.js @@ -0,0 +1,77 @@ +'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') + const nfcCapitalized = 'Café'.normalize('NFC') + + 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') + }) + }) + + 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 new file mode 100644 index 00000000..5a57cd9f --- /dev/null +++ b/lib/move/__tests__/move-unicode-normalization-paths.test.js @@ -0,0 +1,88 @@ +'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') + const nfcCapitalized = 'Café'.normalize('NFC') + + 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() + }) + }) + }) + 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() + }) + }) + }) + }) +}) diff --git a/lib/util/stat.js b/lib/util/stat.js index 6afc894e..040d14c7 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,16 @@ 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().normalize('NFC') === destBaseName.toLowerCase().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