Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions lib/move/__tests__/move-sync-unicode-normalization-paths.test.js
Original file line number Diff line number Diff line change
@@ -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')
})
})
})
})
88 changes: 88 additions & 0 deletions lib/move/__tests__/move-unicode-normalization-paths.test.js
Original file line number Diff line number Diff line change
@@ -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()
})
})
})
})
})
14 changes: 12 additions & 2 deletions lib/util/stat.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.')
Expand Down Expand Up @@ -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.')
Expand All @@ -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
Expand Down
Loading