diff --git a/lib/index.js b/lib/index.js index e840a9dc..d25fd544 100644 --- a/lib/index.js +++ b/lib/index.js @@ -18,6 +18,7 @@ assign(fs, require('./mkdirs')) assign(fs, require('./remove')) assign(fs, require('./json')) assign(fs, require('./move')) +assign(fs, require('./move-sync')) assign(fs, require('./empty')) assign(fs, require('./ensure')) assign(fs, require('./output')) diff --git a/lib/move-sync/__tests__/move-sync-prevent-moving-into-itself.test.js b/lib/move-sync/__tests__/move-sync-prevent-moving-into-itself.test.js new file mode 100644 index 00000000..3685faaf --- /dev/null +++ b/lib/move-sync/__tests__/move-sync-prevent-moving-into-itself.test.js @@ -0,0 +1,189 @@ +'use strict' + +const assert = require('assert') +const os = require('os') +const path = require('path') +const fs = require(process.cwd()) +const klawSync = require('klaw-sync') + +/* global beforeEach, afterEach, describe, it */ + +const FILES = [ + 'file0.txt', + path.join('dir1', 'file1.txt'), + path.join('dir1', 'dir2', 'file2.txt'), + path.join('dir1', 'dir2', 'dir3', 'file3.txt') +] + +const dat0 = 'file0' +const dat1 = 'file1' +const dat2 = 'file2' +const dat3 = 'file3' + +describe('+ moveSync() - prevent moving into itself', () => { + let TEST_DIR, src, dest + + beforeEach(() => { + TEST_DIR = path.join(os.tmpdir(), 'fs-extra', 'move-sync-prevent-moving-into-itself') + src = path.join(TEST_DIR, 'src') + fs.mkdirsSync(src) + + fs.outputFileSync(path.join(src, FILES[0]), dat0) + fs.outputFileSync(path.join(src, FILES[1]), dat1) + fs.outputFileSync(path.join(src, FILES[2]), dat2) + fs.outputFileSync(path.join(src, FILES[3]), dat3) + }) + + afterEach(() => fs.removeSync(TEST_DIR)) + + describe('> when source is a file', () => { + it(`should move the file successfully even when dest parent is 'src/dest'`, () => { + const destFile = path.join(TEST_DIR, 'src', 'dest', 'destfile.txt') + return testSuccessFile(src, destFile) + }) + + it(`should move the file successfully when dest parent is 'src/src_dest'`, () => { + const destFile = path.join(TEST_DIR, 'src', 'src_dest', 'destfile.txt') + return testSuccessFile(src, destFile) + }) + + it(`should move the file successfully when dest parent is 'src/dest_src'`, () => { + const destFile = path.join(TEST_DIR, 'src', 'dest_src', 'destfile.txt') + return testSuccessFile(src, destFile) + }) + + it(`should move the file successfully when dest parent is 'src/dest/src'`, () => { + const destFile = path.join(TEST_DIR, 'src', 'dest', 'src', 'destfile.txt') + return testSuccessFile(src, destFile) + }) + + it(`should move the file successfully when dest parent is 'srcsrc/dest'`, () => { + const destFile = path.join(TEST_DIR, 'srcsrc', 'dest', 'destfile.txt') + return testSuccessFile(src, destFile) + }) + }) + + describe('> when source is a directory', () => { + it(`should move the directory successfully when dest is 'src_dest'`, () => { + dest = path.join(TEST_DIR, 'src_dest') + return testSuccessDir(src, dest) + }) + + it(`should move the directory successfully when dest is 'src-dest'`, () => { + dest = path.join(TEST_DIR, 'src-dest') + return testSuccessDir(src, dest) + }) + + it(`should move the directory successfully when dest is 'dest_src'`, () => { + dest = path.join(TEST_DIR, 'dest_src') + return testSuccessDir(src, dest) + }) + + it(`should move the directory successfully when dest is 'src_dest/src'`, () => { + dest = path.join(TEST_DIR, 'src_dest', 'src') + return testSuccessDir(src, dest) + }) + + it(`should move the directory successfully when dest is 'src-dest/src'`, () => { + dest = path.join(TEST_DIR, 'src-dest', 'src') + return testSuccessDir(src, dest) + }) + + it(`should move the directory successfully when dest is 'dest_src/src'`, () => { + dest = path.join(TEST_DIR, 'dest_src', 'src') + return testSuccessDir(src, dest) + }) + + it(`should move the directory successfully when dest is 'src_src/dest'`, () => { + dest = path.join(TEST_DIR, 'src_src', 'dest') + return testSuccessDir(src, dest) + }) + + it(`should move the directory successfully when dest is 'src-src/dest'`, () => { + dest = path.join(TEST_DIR, 'src-src', 'dest') + return testSuccessDir(src, dest) + }) + + it(`should move the directory successfully when dest is 'srcsrc/dest'`, () => { + dest = path.join(TEST_DIR, 'srcsrc', 'dest') + return testSuccessDir(src, dest) + }) + + it(`should move the directory successfully when dest is 'dest/src'`, () => { + dest = path.join(TEST_DIR, 'dest', 'src') + return testSuccessDir(src, dest) + }) + + it('should move the directory successfully when dest is very nested that all its parents need to be created', () => { + dest = path.join(TEST_DIR, 'dest', 'src', 'foo', 'bar', 'baz', 'qux', 'quux', 'waldo', + 'grault', 'garply', 'fred', 'plugh', 'thud', 'some', 'highly', 'deeply', + 'badly', 'nasty', 'crazy', 'mad', 'nested', 'dest') + assert(!fs.existsSync(dest)) + return testSuccessDir(src, dest) + }) + + it(`should throw error when dest is 'src/dest'`, () => { + dest = path.join(TEST_DIR, 'src', 'dest') + return testError(src, dest) + }) + + it(`should throw error when dest is 'src/src_dest'`, () => { + dest = path.join(TEST_DIR, 'src', 'src_dest') + return testError(src, dest) + }) + + it(`should throw error when dest is 'src/dest_src'`, () => { + dest = path.join(TEST_DIR, 'src', 'dest_src') + return testError(src, dest) + }) + + it(`should throw error when dest is 'src/dest/src'`, () => { + dest = path.join(TEST_DIR, 'src', 'dest', 'src') + return testError(src, dest) + }) + }) +}) + +function testSuccessFile (src, destFile) { + const srcFile = path.join(src, FILES[0]) + + fs.moveSync(srcFile, destFile) + + const o0 = fs.readFileSync(destFile, 'utf8') + assert.strictEqual(o0, dat0, 'file contents matched') + assert(!fs.existsSync(srcFile)) +} + +function testSuccessDir (src, dest) { + const srclen = klawSync(src).length + // assert src has contents + assert(srclen > 2) + + fs.moveSync(src, dest) + + const destlen = klawSync(dest).length + + // assert src and dest length are the same + assert.strictEqual(destlen, srclen, 'src and dest length should be equal') + + const o0 = fs.readFileSync(path.join(dest, FILES[0]), 'utf8') + const o1 = fs.readFileSync(path.join(dest, FILES[1]), 'utf8') + const o2 = fs.readFileSync(path.join(dest, FILES[2]), 'utf8') + const o3 = fs.readFileSync(path.join(dest, FILES[3]), 'utf8') + + assert.strictEqual(o0, dat0, 'file contents matched') + assert.strictEqual(o1, dat1, 'file contents matched') + assert.strictEqual(o2, dat2, 'file contents matched') + assert.strictEqual(o3, dat3, 'file contents matched') + assert(!fs.existsSync(src)) +} + +function testError (src, dest) { + try { + fs.moveSync(src, dest) + } catch (err) { + assert.strictEqual(err.message, `Cannot move '${src}' into itself '${dest}'.`) + assert(fs.existsSync(src)) + assert(!fs.existsSync(dest)) + } +} diff --git a/lib/move-sync/__tests__/move-sync.test.js b/lib/move-sync/__tests__/move-sync.test.js new file mode 100644 index 00000000..0872dfcc --- /dev/null +++ b/lib/move-sync/__tests__/move-sync.test.js @@ -0,0 +1,352 @@ +'use strict' + +const fs = require('graceful-fs') +const os = require('os') +const fse = require(process.cwd()) +const path = require('path') +const assert = require('assert') +const rimraf = require('rimraf') + +/* global afterEach, beforeEach, describe, it */ + +function createSyncErrFn (errCode) { + const fn = function () { + const err = new Error() + err.code = errCode + throw err + } + return fn +} + +const originalRenameSync = fs.renameSync +const originalLinkSync = fs.linkSync + +function setUpMockFs (errCode) { + fs.renameSync = createSyncErrFn(errCode) + fs.linkSync = createSyncErrFn(errCode) +} + +function tearDownMockFs () { + fs.renameSync = originalRenameSync + fs.linkSync = originalLinkSync +} + +describe('moveSync()', () => { + let TEST_DIR + + beforeEach(() => { + TEST_DIR = path.join(os.tmpdir(), 'fs-extra', 'move-sync') + + fse.emptyDirSync(TEST_DIR) + + // Create fixtures: + fs.writeFileSync(path.join(TEST_DIR, 'a-file'), 'sonic the hedgehog\n') + fs.mkdirSync(path.join(TEST_DIR, 'a-folder')) + fs.writeFileSync(path.join(TEST_DIR, 'a-folder/another-file'), 'tails\n') + fs.mkdirSync(path.join(TEST_DIR, 'a-folder/another-folder')) + fs.writeFileSync(path.join(TEST_DIR, 'a-folder/another-folder/file3'), 'knuckles\n') + }) + + afterEach(done => rimraf(TEST_DIR, done)) + + it('should not move if src and dest are the same', () => { + const src = `${TEST_DIR}/a-file` + const dest = `${TEST_DIR}/a-file` + + fse.moveSync(src, dest) + + // assert src not affected + const contents = fs.readFileSync(src, 'utf8') + const expected = /^sonic the hedgehog\r?\n$/ + assert.ok(contents.match(expected), `${contents} match ${expected}`) + }) + + it('should rename a file on the same device', () => { + const src = `${TEST_DIR}/a-file` + const dest = `${TEST_DIR}/a-file-dest` + + fse.moveSync(src, dest) + + const contents = fs.readFileSync(dest, 'utf8') + const expected = /^sonic the hedgehog\r?\n$/ + assert.ok(contents.match(expected), `${contents} match ${expected}`) + }) + + it('should not overwrite the destination by default', () => { + const src = `${TEST_DIR}/a-file` + const dest = `${TEST_DIR}/a-folder/another-file` + + // verify file exists already + assert(fs.existsSync(dest)) + + try { + fse.moveSync(src, dest) + } catch (err) { + assert.ok(err && err.code === 'EEXIST', 'throw EEXIST') + } + }) + + it('should not overwrite if overwrite = false', () => { + const src = `${TEST_DIR}/a-file` + const dest = `${TEST_DIR}/a-folder/another-file` + + // verify file exists already + assert(fs.existsSync(dest)) + + try { + fse.moveSync(src, dest, {overwrite: false}) + } catch (err) { + assert.ok(err && err.code === 'EEXIST', 'throw EEXIST') + } + }) + + it('should overwrite file if overwrite = true', () => { + const src = `${TEST_DIR}/a-file` + const dest = `${TEST_DIR}/a-folder/another-file` + + // verify file exists already + assert(fs.existsSync(dest)) + + fse.moveSync(src, dest, {overwrite: true}) + + const contents = fs.readFileSync(dest, 'utf8') + const expected = /^sonic the hedgehog\r?\n$/ + assert.ok(contents.match(expected), `${contents} match ${expected}`) + }) + + it('should overwrite the destination directory if overwrite = true', function (done) { + // Tests fail on appveyor/Windows due to + // https://github.com/isaacs/node-graceful-fs/issues/98. + // Workaround by increasing the timeout by a minute (because + // graceful times out after a minute). + this.timeout(90000) + + // Create src + const src = path.join(TEST_DIR, 'src') + fse.ensureDirSync(src) + fse.mkdirsSync(path.join(src, 'some-folder')) + fs.writeFileSync(path.join(src, 'some-file'), 'hi') + + const dest = path.join(TEST_DIR, 'a-folder') + + // verify dest has stuff in it + const pathsBefore = fs.readdirSync(dest) + assert(pathsBefore.indexOf('another-file') >= 0) + assert(pathsBefore.indexOf('another-folder') >= 0) + + fse.moveSync(src, dest, {overwrite: true}) + + // verify dest does not have old stuff + const pathsAfter = fs.readdirSync(dest) + assert.strictEqual(pathsAfter.indexOf('another-file'), -1) + assert.strictEqual(pathsAfter.indexOf('another-folder'), -1) + + // verify dest has new stuff + assert(pathsAfter.indexOf('some-file') >= 0) + assert(pathsAfter.indexOf('some-folder') >= 0) + done() + }) + + it('should create directory structure by default', () => { + const src = `${TEST_DIR}/a-file` + const dest = `${TEST_DIR}/does/not/exist/a-file-dest` + + // verify dest directory does not exist + assert(!fs.existsSync(path.dirname(dest))) + + fse.moveSync(src, dest) + + const contents = fs.readFileSync(dest, 'utf8') + const expected = /^sonic the hedgehog\r?\n$/ + assert.ok(contents.match(expected), `${contents} match ${expected}`) + }) + + it('should work across devices', () => { + const src = `${TEST_DIR}/a-file` + const dest = `${TEST_DIR}/a-file-dest` + + setUpMockFs('EXDEV') + + fse.moveSync(src, dest) + + const contents = fs.readFileSync(dest, 'utf8') + const expected = /^sonic the hedgehog\r?\n$/ + assert.ok(contents.match(expected), `${contents} match ${expected}`) + tearDownMockFs() + }) + + it('should move folders', () => { + const src = `${TEST_DIR}/a-folder` + const dest = `${TEST_DIR}/a-folder-dest` + + // verify it doesn't exist + assert(!fs.existsSync(dest)) + + fse.moveSync(src, dest) + + const contents = fs.readFileSync(dest + '/another-file', 'utf8') + const expected = /^tails\r?\n$/ + assert.ok(contents.match(expected), `${contents} match ${expected}`) + }) + + it('should move folders across devices with EISDIR error', () => { + const src = `${TEST_DIR}/a-folder` + const dest = `${TEST_DIR}/a-folder-dest` + + setUpMockFs('EISDIR') + + fse.moveSync(src, dest) + + const contents = fs.readFileSync(dest + '/another-folder/file3', 'utf8') + const expected = /^knuckles\r?\n$/ + assert.ok(contents.match(expected), `${contents} match ${expected}`) + tearDownMockFs() + }) + + it('should overwrite folders across devices', () => { + const src = `${TEST_DIR}/a-folder` + const dest = `${TEST_DIR}/a-folder-dest` + + fs.mkdirSync(dest) + + setUpMockFs('EXDEV') + + fse.moveSync(src, dest, {overwrite: true}) + + const contents = fs.readFileSync(dest + '/another-folder/file3', 'utf8') + const expected = /^knuckles\r?\n$/ + assert.ok(contents.match(expected), `${contents} match ${expected}`) + tearDownMockFs() + }) + + it('should move folders across devices with EXDEV error', () => { + const src = `${TEST_DIR}/a-folder` + const dest = `${TEST_DIR}/a-folder-dest` + + setUpMockFs('EXDEV') + + fse.moveSync(src, dest) + + const contents = fs.readFileSync(dest + '/another-folder/file3', 'utf8') + const expected = /^knuckles\r?\n$/ + assert.ok(contents.match(expected), `${contents} match ${expected}`) + tearDownMockFs() + }) + + it('should move folders across devices with EPERM error', () => { + const src = `${TEST_DIR}/a-folder` + const dest = `${TEST_DIR}/a-folder-dest` + + setUpMockFs('EPERM') + + fse.moveSync(src, dest) + + const contents = fs.readFileSync(dest + '/another-folder/file3', 'utf8') + const expected = /^knuckles\r?\n$/ + assert.ok(contents.match(expected), `${contents} match ${expected}`) + tearDownMockFs() + }) + + it('should move folders across devices with ENOTSUP error', () => { + const src = `${TEST_DIR}/a-folder` + const dest = `${TEST_DIR}/a-folder-dest` + + setUpMockFs('ENOTSUP') + + fse.moveSync(src, dest) + + const contents = fs.readFileSync(dest + '/another-folder/file3', 'utf8') + const expected = /^knuckles\r?\n$/ + assert.ok(contents.match(expected), `${contents} match ${expected}`) + tearDownMockFs() + }) + + describe('clobber', () => { + it('should be an alias for overwrite', () => { + const src = `${TEST_DIR}/a-file` + const dest = `${TEST_DIR}/a-folder/another-file` + + // verify file exists already + assert(fs.existsSync(dest)) + + fse.moveSync(src, dest, {clobber: true}) + + const contents = fs.readFileSync(dest, 'utf8') + const expected = /^sonic the hedgehog\r?\n$/ + assert.ok(contents.match(expected), `${contents} match ${expected}`) + }) + }) + + describe('> when trying to a move a folder into itself', () => { + it('should produce an error', () => { + const SRC_DIR = path.join(TEST_DIR, 'src') + const DEST_DIR = path.join(TEST_DIR, 'src', 'dest') + + assert(!fs.existsSync(SRC_DIR)) + fs.mkdirSync(SRC_DIR) + assert(fs.existsSync(SRC_DIR)) + + try { + fse.moveSync(SRC_DIR, DEST_DIR) + } catch (err) { + assert(err.message, `Cannot move ${SRC_DIR} into itself ${DEST_DIR}.`) + assert(fs.existsSync(SRC_DIR)) + assert(!fs.existsSync(DEST_DIR)) + } + }) + }) + + describe('> when trying to a move a file into its parent subdirectory', () => { + it('should move successfully', () => { + const src = `${TEST_DIR}/a-file` + const dest = `${TEST_DIR}/dest/a-file-dest` + + fse.moveSync(src, dest) + + const contents = fs.readFileSync(dest, 'utf8') + const expected = /^sonic the hedgehog\r?\n$/ + assert.ok(contents.match(expected), `${contents} match ${expected}`) + }) + }) + + describe('> when actually trying to a move a folder across devices', () => { + const differentDevice = '/mnt' + let __skipTests = false + + // must set this up, if not, exit silently + if (!fs.existsSync(differentDevice)) { + console.log('Skipping cross-device moveSync test') + __skipTests = true + } + + // make sure we have permission on device + try { + fs.writeFileSync(path.join(differentDevice, 'file'), 'hi') + } catch (err) { + console.log("Can't write to device. Skipping moveSync test.") + __skipTests = true + } + + const _it = __skipTests ? it.skip : it + + describe('> just the folder', () => { + _it('should move the folder', () => { + const src = '/mnt/some/weird/dir-really-weird' + const dest = path.join(TEST_DIR, 'device-weird') + + if (!fs.existsSync(src)) { + fse.mkdirpSync(src) + } + + assert(!fs.existsSync(dest)) + + assert(fs.lstatSync(src).isDirectory()) + + fse.moveSync(src, dest) + + assert(fs.existsSync(dest)) + assert(fs.lstatSync(dest).isDirectory()) + }) + }) + }) +}) diff --git a/lib/move-sync/index.js b/lib/move-sync/index.js new file mode 100644 index 00000000..435fbe15 --- /dev/null +++ b/lib/move-sync/index.js @@ -0,0 +1,117 @@ +'use strict' + +const fs = require('graceful-fs') +const path = require('path') +const copySync = require('../copy-sync').copySync +const removeSync = require('../remove').removeSync +const mkdirpSync = require('../mkdirs').mkdirsSync + +function moveSync (src, dest, options) { + options = options || {} + const overwrite = options.overwrite || options.clobber || false + + src = path.resolve(src) + dest = path.resolve(dest) + + if (src === dest) return + + if (isSrcSubdir(src, dest)) throw new Error(`Cannot move '${src}' into itself '${dest}'.`) + + mkdirpSync(path.dirname(dest)) + tryRenameSync() + + function tryRenameSync () { + if (overwrite) { + try { + return fs.renameSync(src, dest) + } catch (err) { + if (err.code === 'ENOTEMPTY' || err.code === 'EEXIST' || err.code === 'EPERM') { + removeSync(dest) + options.overwrite = false // just overwriteed it, no need to do it again + return moveSync(src, dest, options) + } + + if (err.code !== 'EXDEV') throw err + return moveSyncAcrossDevice(src, dest, overwrite) + } + } else { + try { + fs.linkSync(src, dest) + return fs.unlinkSync(src) + } catch (err) { + if (err.code === 'EXDEV' || err.code === 'EISDIR' || err.code === 'EPERM' || err.code === 'ENOTSUP') { + return moveSyncAcrossDevice(src, dest, overwrite) + } + throw err + } + } + } +} + +function moveSyncAcrossDevice (src, dest, overwrite) { + const stat = fs.statSync(src) + + if (stat.isDirectory()) { + return moveDirSyncAcrossDevice(src, dest, overwrite) + } else { + return moveFileSyncAcrossDevice(src, dest, overwrite) + } +} + +function moveFileSyncAcrossDevice (src, dest, overwrite) { + const BUF_LENGTH = 64 * 1024 + const _buff = Buffer.alloc(BUF_LENGTH) + + const flags = overwrite ? 'w' : 'wx' + + const fdr = fs.openSync(src, 'r') + const stat = fs.fstatSync(fdr) + const fdw = fs.openSync(dest, flags, stat.mode) + let bytesRead = 1 + let pos = 0 + + while (bytesRead > 0) { + bytesRead = fs.readSync(fdr, _buff, 0, BUF_LENGTH, pos) + fs.writeSync(fdw, _buff, 0, bytesRead) + pos += bytesRead + } + + fs.closeSync(fdr) + fs.closeSync(fdw) + return fs.unlinkSync(src) +} + +function moveDirSyncAcrossDevice (src, dest, overwrite) { + const options = { + overwrite: false + } + + if (overwrite) { + removeSync(dest) + tryCopySync() + } else { + tryCopySync() + } + + function tryCopySync () { + copySync(src, dest, options) + return removeSync(src) + } +} + +// return true if dest is a subdir of src, otherwise false. +// extract dest base dir and check if that is the same as src basename +function isSrcSubdir (src, dest) { + try { + return fs.statSync(src).isDirectory() && + src !== dest && + dest.indexOf(src) > -1 && + dest.split(path.dirname(src) + path.sep)[1].split(path.sep)[0] === path.basename(src) + } catch (e) { + return false + } +} + +module.exports = { + moveSync +} diff --git a/package.json b/package.json index 3b545188..bdd2d41c 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "coveralls": "^2.11.2", "istanbul": "^0.4.5", "klaw": "^1.0.0", + "klaw-sync": "^1.1.2", "minimist": "^1.1.1", "mocha": "^3.1.2", "proxyquire": "^1.7.10",