diff --git a/lib/dest/options.js b/lib/dest/options.js index 14962d23..c8ef6392 100644 --- a/lib/dest/options.js +++ b/lib/dest/options.js @@ -1,5 +1,9 @@ 'use strict'; +var os = require('os'); + +var isWindows = (os.platform() === 'win32'); + var config = { cwd: { type: 'string', @@ -29,6 +33,18 @@ var config = { type: ['string', 'boolean'], default: false, }, + // Symlink options + useJunctions: { + type: 'boolean', + default: function(file) { + var isDirectory = file.isDirectory(); + return (isWindows && isDirectory); + }, + }, + relative: { + type: 'boolean', + default: false, + }, }; module.exports = config; diff --git a/lib/dest/write-contents/index.js b/lib/dest/write-contents/index.js index a4adeefa..439a47b3 100644 --- a/lib/dest/write-contents/index.js +++ b/lib/dest/write-contents/index.js @@ -12,6 +12,11 @@ var fo = require('../../file-operations'); function writeContents(optResolver) { function writeFile(file, enc, callback) { + // Write it as a symlink + if (file.symlink) { + return writeSymbolicLink(file, optResolver, onWritten); + } + // If directory then mkdirp it if (file.isDirectory()) { return writeDir(file, optResolver, onWritten); @@ -22,11 +27,6 @@ function writeContents(optResolver) { return writeStream(file, optResolver, onWritten); } - // Write it as a symlink - if (file.symlink) { - return writeSymbolicLink(file, optResolver, onWritten); - } - // Write it like normal if (file.isBuffer()) { return writeBuffer(file, optResolver, onWritten); diff --git a/lib/dest/write-contents/write-symbolic-link.js b/lib/dest/write-contents/write-symbolic-link.js index c33b15bb..31b15777 100644 --- a/lib/dest/write-contents/write-symbolic-link.js +++ b/lib/dest/write-contents/write-symbolic-link.js @@ -1,18 +1,44 @@ 'use strict'; -var fs = require('graceful-fs'); +var path = require('path'); var fo = require('../../file-operations'); function writeSymbolicLink(file, optResolver, onWritten) { - // TODO handle symlinks properly - fs.symlink(file.symlink, file.path, function(symlinkErr) { - if (fo.isFatalOverwriteError(symlinkErr)) { - return onWritten(symlinkErr); - } - - onWritten(); - }); + var srcPath = file.symlink; + + var isDirectory = file.isDirectory(); + + // This option provides a way to create a Junction instead of a + // Directory symlink on Windows. This comes with the following caveats: + // * NTFS Junctions cannot be relative. + // * NTFS Junctions MUST be directories. + // * NTFS Junctions must be on the same file system. + // * Most products CANNOT detect a directory is a Junction: + // This has the side effect of possibly having a whole directory + // deleted when a product is deleting the Junction directory. + // For example, JetBrains product lines will delete the entire + // contents of the TARGET directory because the product does not + // realize it's a symlink as the JVM and Node return false for isSymlink. + var useJunctions = optResolver.resolve('useJunctions', file); + + var symDirType = useJunctions ? 'junction' : 'dir'; + var symType = isDirectory ? symDirType : 'file'; + var isRelative = optResolver.resolve('relative', file); + + // This is done inside prepareWrite to use the adjusted file.base property + if (isRelative && !useJunctions) { + srcPath = path.relative(file.base, srcPath); + } + + var flag = optResolver.resolve('flag', file); + + var opts = { + flag: flag, + type: symType, + }; + + fo.symlink(srcPath, file.path, opts, onWritten); } module.exports = writeSymbolicLink; diff --git a/lib/file-operations.js b/lib/file-operations.js index 4e9e5dfc..3a8aadd1 100644 --- a/lib/file-operations.js +++ b/lib/file-operations.js @@ -243,6 +243,31 @@ function updateMetadata(fd, file, callback) { } } +function symlink(srcPath, destPath, opts, callback) { + // Because fs.symlink does not allow atomic overwrite option with flags, we + // delete and recreate if the link already exists and overwrite is true. + if (opts.flag === 'w') { + // TODO What happens when we call unlink with windows junctions? + fs.unlink(destPath, onUnlink); + } else { + fs.symlink(srcPath, destPath, opts.type, onSymlink); + } + + function onUnlink(unlinkErr) { + if (isFatalUnlinkError(unlinkErr)) { + return callback(unlinkErr); + } + fs.symlink(srcPath, destPath, opts.type, onSymlink); + } + + function onSymlink(symlinkErr) { + if (isFatalOverwriteError(symlinkErr, opts.flag)) { + return callback(symlinkErr); + } + callback(); + } +} + /* Custom writeFile implementation because we need access to the file descriptor after the write is complete. @@ -388,6 +413,7 @@ module.exports = { getOwnerDiff: getOwnerDiff, isOwner: isOwner, updateMetadata: updateMetadata, + symlink: symlink, writeFile: writeFile, createWriteStream: createWriteStream, }; diff --git a/lib/symlink/index.js b/lib/symlink/index.js index e76a0a08..54e24da0 100644 --- a/lib/symlink/index.js +++ b/lib/symlink/index.js @@ -1,18 +1,13 @@ 'use strict'; -var path = require('path'); - -var fs = require('graceful-fs'); var pumpify = require('pumpify'); -var through = require('through2'); var lead = require('lead'); var mkdirpStream = require('fs-mkdirp-stream'); var createResolver = require('resolve-options'); -var fo = require('../file-operations'); - var config = require('./options'); var prepare = require('./prepare'); +var linkFile = require('./link-file'); var folderConfig = { outFolder: { @@ -24,59 +19,6 @@ function symlink(outFolder, opt) { var optResolver = createResolver(config, opt); var folderResolver = createResolver(folderConfig, { outFolder: outFolder }); - function linkFile(file, enc, callback) { - // Fetch the path as it was before prepare.dest() - var srcPath = file.history[file.history.length - 2]; - - var isDirectory = file.isDirectory(); - - // This option provides a way to create a Junction instead of a - // Directory symlink on Windows. This comes with the following caveats: - // * NTFS Junctions cannot be relative. - // * NTFS Junctions MUST be directories. - // * NTFS Junctions must be on the same file system. - // * Most products CANNOT detect a directory is a Junction: - // This has the side effect of possibly having a whole directory - // deleted when a product is deleting the Junction directory. - // For example, JetBrains product lines will delete the entire - // contents of the TARGET directory because the product does not - // realize it's a symlink as the JVM and Node return false for isSymlink. - var useJunctions = optResolver.resolve('useJunctions', file); - - var symDirType = useJunctions ? 'junction' : 'dir'; - var symType = isDirectory ? symDirType : 'file'; - var isRelative = optResolver.resolve('relative', file); - - // This is done inside prepareWrite to use the adjusted file.base property - if (isRelative && !useJunctions) { - srcPath = path.relative(file.base, srcPath); - } - - // Because fs.symlink does not allow atomic overwrite option with flags, we - // delete and recreate if the link already exists and overwrite is true. - var flag = optResolver.resolve('flag', file); - if (flag === 'w') { - // TODO What happens when we call unlink with windows junctions? - fs.unlink(file.path, onUnlink); - } else { - fs.symlink(srcPath, file.path, symType, onSymlink); - } - - function onUnlink(unlinkErr) { - if (fo.isFatalUnlinkError(unlinkErr)) { - return callback(unlinkErr); - } - fs.symlink(srcPath, file.path, symType, onSymlink); - } - - function onSymlink(symlinkErr) { - if (fo.isFatalOverwriteError(symlinkErr, flag)) { - return callback(symlinkErr); - } - callback(null, file); - } - } - function dirpath(file, callback) { var dirMode = optResolver.resolve('dirMode', file); @@ -86,7 +28,7 @@ function symlink(outFolder, opt) { var stream = pumpify.obj( prepare(folderResolver, optResolver), mkdirpStream.obj(dirpath), - through.obj(linkFile) + linkFile(optResolver) ); // Sink the stream to start flowing diff --git a/lib/symlink/link-file.js b/lib/symlink/link-file.js new file mode 100644 index 00000000..04aa1dc4 --- /dev/null +++ b/lib/symlink/link-file.js @@ -0,0 +1,60 @@ +'use strict'; + +var path = require('path'); + +var through = require('through2'); + +var fo = require('../file-operations'); + +function linkStream(optResolver) { + + function linkFile(file, enc, callback) { + // Fetch the path as it was before prepare.dest() + var srcPath = file.history[file.history.length - 2]; + + var isDirectory = file.isDirectory(); + + // This option provides a way to create a Junction instead of a + // Directory symlink on Windows. This comes with the following caveats: + // * NTFS Junctions cannot be relative. + // * NTFS Junctions MUST be directories. + // * NTFS Junctions must be on the same file system. + // * Most products CANNOT detect a directory is a Junction: + // This has the side effect of possibly having a whole directory + // deleted when a product is deleting the Junction directory. + // For example, JetBrains product lines will delete the entire + // contents of the TARGET directory because the product does not + // realize it's a symlink as the JVM and Node return false for isSymlink. + var useJunctions = optResolver.resolve('useJunctions', file); + + var symDirType = useJunctions ? 'junction' : 'dir'; + var symType = isDirectory ? symDirType : 'file'; + var isRelative = optResolver.resolve('relative', file); + + // This is done inside prepareWrite to use the adjusted file.base property + if (isRelative && !useJunctions) { + srcPath = path.relative(file.base, srcPath); + } + + var flag = optResolver.resolve('flag', file); + + var opts = { + flag: flag, + type: symType, + }; + + fo.symlink(srcPath, file.path, opts, onSymlink); + + function onSymlink(symlinkErr) { + if (symlinkErr) { + return callback(symlinkErr); + } + + callback(null, file); + } + } + + return through.obj(linkFile); +} + +module.exports = linkStream; diff --git a/test/dest-symlinks.js b/test/dest-symlinks.js new file mode 100644 index 00000000..a542d03d --- /dev/null +++ b/test/dest-symlinks.js @@ -0,0 +1,479 @@ +'use strict'; + +var path = require('path'); + +var fs = require('graceful-fs'); +var File = require('vinyl'); +var expect = require('expect'); +var miss = require('mississippi'); + +var vfs = require('../'); + +var cleanup = require('./utils/cleanup'); +var isWindows = require('./utils/is-windows'); +var isDirectory = require('./utils/is-directory-mock'); +var testConstants = require('./utils/test-constants'); + +var from = miss.from; +var pipe = miss.pipe; +var concat = miss.concat; + +var inputBase = testConstants.inputBase; +var outputBase = testConstants.outputBase; +var inputPath = testConstants.inputPath; +var outputPath = testConstants.outputPath; +var inputDirpath = testConstants.inputDirpath; +var outputDirpath = testConstants.outputDirpath; +var contents = testConstants.contents; + +var clean = cleanup(outputBase); + +describe('.dest() with symlinks', function() { + + beforeEach(clean); + afterEach(clean); + + it('creates symlinks when the `symlink` attribute is set on the file', function(done) { + var file = new File({ + base: inputBase, + path: inputPath, + contents: null, + }); + + // `src()` adds this side-effect with `resolveSymlinks` option set to false + file.symlink = inputPath; + + function assert(files) { + var symlink = fs.readlinkSync(outputPath); + + expect(files.length).toEqual(1); + expect(file.symlink).toEqual(symlink); + expect(files[0].symlink).toEqual(symlink); + expect(files[0].path).toEqual(outputPath); + } + + pipe([ + from.obj([file]), + vfs.dest(outputBase), + concat(assert), + ], done); + }); + + it('can create relative links', function(done) { + var file = new File({ + base: inputBase, + path: inputPath, + contents: null, + }); + + // `src()` adds this side-effect with `resolveSymlinks` option set to false + file.symlink = inputPath; + + function assert(files) { + var outputLink = fs.readlinkSync(outputPath); + + expect(files.length).toEqual(1); + expect(outputLink).toEqual(path.normalize('../fixtures/test.txt')); + } + + pipe([ + from.obj([file]), + vfs.dest(outputBase, { relative: true }), + concat(assert), + ], done); + }); + + it('(*nix) creates a link for a directory', function(done) { + if (isWindows) { + this.skip(); + return; + } + + var file = new File({ + base: inputBase, + path: inputDirpath, + contents: null, + stat: { + isDirectory: isDirectory, + }, + }); + + // `src()` adds this side-effect with `resolveSymlinks` option set to false + file.symlink = inputDirpath; + + function assert(files) { + var stats = fs.statSync(outputDirpath); + var lstats = fs.lstatSync(outputDirpath); + var outputLink = fs.readlinkSync(outputDirpath); + + expect(files.length).toEqual(1); + expect(outputLink).toEqual(inputDirpath); + expect(stats.isDirectory()).toEqual(true); + expect(lstats.isDirectory()).toEqual(false); + } + + pipe([ + from.obj([file]), + vfs.dest(outputBase), + concat(assert), + ], done); + }); + + it('(windows) creates a junction for a directory', function(done) { + if (!isWindows) { + this.skip(); + return; + } + + var file = new File({ + base: inputBase, + path: inputDirpath, + contents: null, + stat: { + isDirectory: isDirectory, + }, + }); + + // `src()` adds this side-effect with `resolveSymlinks` option set to false + file.symlink = inputDirpath; + + function assert(files) { + var stats = fs.statSync(outputDirpath); + var lstats = fs.lstatSync(outputDirpath); + var outputLink = fs.readlinkSync(outputDirpath); + + expect(files.length).toEqual(1); + // When creating a junction, it seems Windows appends a separator + expect(outputLink).toEqual(inputDirpath + path.sep); + expect(stats.isDirectory()).toEqual(true); + expect(lstats.isDirectory()).toEqual(false); + } + + pipe([ + from.obj([file]), + vfs.dest(outputBase), + concat(assert), + ], done); + }); + + it('(windows) options can disable junctions for a directory', function(done) { + if (!isWindows) { + this.skip(); + return; + } + + var file = new File({ + base: inputBase, + path: inputDirpath, + contents: null, + stat: { + isDirectory: isDirectory, + }, + }); + + // `src()` adds this side-effect with `resolveSymlinks` option set to false + file.symlink = inputDirpath; + + function assert(files) { + var stats = fs.statSync(outputDirpath); + var lstats = fs.lstatSync(outputDirpath); + var outputLink = fs.readlinkSync(outputDirpath); + + expect(files.length).toEqual(1); + expect(outputLink).toEqual(inputDirpath); + expect(stats.isDirectory()).toEqual(true); + expect(lstats.isDirectory()).toEqual(false); + } + + pipe([ + from.obj([file]), + vfs.dest(outputBase, { useJunctions: false }), + concat(assert), + ], done); + }); + + it('(windows) options can disable junctions for a directory (as a function)', function(done) { + if (!isWindows) { + this.skip(); + return; + } + + var file = new File({ + base: inputBase, + path: inputDirpath, + contents: null, + stat: { + isDirectory: isDirectory, + }, + }); + + // `src()` adds this side-effect with `resolveSymlinks` option set to false + file.symlink = inputDirpath; + + function useJunctions(f) { + expect(f).toExist(); + expect(f).toBe(file); + return false; + } + + function assert(files) { + var stats = fs.statSync(outputDirpath); + var lstats = fs.lstatSync(outputDirpath); + var outputLink = fs.readlinkSync(outputDirpath); + + expect(files.length).toEqual(1); + expect(outputLink).toEqual(inputDirpath); + expect(stats.isDirectory()).toEqual(true); + expect(lstats.isDirectory()).toEqual(false); + } + + pipe([ + from.obj([file]), + vfs.dest(outputBase, { useJunctions: useJunctions }), + concat(assert), + ], done); + }); + + it('(*nix) can create relative links for directories', function(done) { + if (isWindows) { + this.skip(); + return; + } + + var file = new File({ + base: inputBase, + path: inputDirpath, + contents: null, + stat: { + isDirectory: isDirectory, + }, + }); + + // `src()` adds this side-effect with `resolveSymlinks` option set to false + file.symlink = inputDirpath; + + function assert(files) { + var stats = fs.statSync(outputDirpath); + var lstats = fs.lstatSync(outputDirpath); + var outputLink = fs.readlinkSync(outputDirpath); + + expect(files.length).toEqual(1); + expect(outputLink).toEqual(path.normalize('../fixtures/foo')); + expect(stats.isDirectory()).toEqual(true); + expect(lstats.isDirectory()).toEqual(false); + } + + pipe([ + from.obj([file]), + vfs.dest(outputBase, { relative: true }), + concat(assert), + ], done); + }); + + it('(windows) relative option is ignored when junctions are used', function(done) { + if (!isWindows) { + this.skip(); + return; + } + + var file = new File({ + base: inputBase, + path: inputDirpath, + contents: null, + stat: { + isDirectory: isDirectory, + }, + }); + + // `src()` adds this side-effect with `resolveSymlinks` option set to false + file.symlink = inputDirpath; + + function assert(files) { + var stats = fs.statSync(outputDirpath); + var lstats = fs.lstatSync(outputDirpath); + var outputLink = fs.readlinkSync(outputDirpath); + + expect(files.length).toEqual(1); + // When creating a junction, it seems Windows appends a separator + expect(outputLink).toEqual(inputDirpath + path.sep); + expect(stats.isDirectory()).toEqual(true); + expect(lstats.isDirectory()).toEqual(false); + } + + pipe([ + from.obj([file]), + vfs.dest(outputBase, { useJunctions: true, relative: true }), + concat(assert), + ], done); + }); + + it('(windows) can create relative links for directories when junctions are disabled', function(done) { + if (!isWindows) { + this.skip(); + return; + } + + var file = new File({ + base: inputBase, + path: inputDirpath, + contents: null, + stat: { + isDirectory: isDirectory, + }, + }); + + // `src()` adds this side-effect with `resolveSymlinks` option set to false + file.symlink = inputDirpath; + + function assert(files) { + var stats = fs.statSync(outputDirpath); + var lstats = fs.lstatSync(outputDirpath); + var outputLink = fs.readlinkSync(outputDirpath); + + expect(files.length).toEqual(1); + expect(files).toInclude(file); + expect(files[0].base).toEqual(outputBase, 'base should have changed'); + expect(files[0].path).toEqual(outputDirpath, 'path should have changed'); + expect(outputLink).toEqual(path.normalize('../fixtures/foo')); + expect(stats.isDirectory()).toEqual(true); + expect(lstats.isDirectory()).toEqual(false); + } + + pipe([ + from.obj([file]), + vfs.dest(outputBase, { useJunctions: false, relative: true }), + concat(assert), + ], done); + }); + + it('does not overwrite links with overwrite option set to false', function(done) { + var existingContents = 'Lorem Ipsum'; + + var file = new File({ + base: inputBase, + path: inputPath, + contents: null, + }); + + // `src()` adds this side-effect with `resolveSymlinks` option set to false + file.symlink = inputPath; + + function assert(files) { + var outputContents = fs.readFileSync(outputPath, 'utf8'); + + expect(files.length).toEqual(1); + expect(outputContents).toEqual(existingContents); + } + + // Write expected file which should not be overwritten + fs.mkdirSync(outputBase); + fs.writeFileSync(outputPath, existingContents); + + pipe([ + from.obj([file]), + vfs.dest(outputBase, { overwrite: false }), + concat(assert), + ], done); + }); + + + it('overwrites links with overwrite option set to true', function(done) { + var existingContents = 'Lorem Ipsum'; + + var file = new File({ + base: inputBase, + path: inputPath, + contents: null, + }); + + // `src()` adds this side-effect with `resolveSymlinks` option set to false + file.symlink = inputPath; + + function assert(files) { + var outputContents = fs.readFileSync(outputPath, 'utf8'); + + expect(files.length).toEqual(1); + expect(outputContents).toEqual(contents); + } + + // This should be overwritten + fs.mkdirSync(outputBase); + fs.writeFileSync(outputPath, existingContents); + + pipe([ + from.obj([file]), + vfs.dest(outputBase, { overwrite: true }), + concat(assert), + ], done); + }); + + it('does not overwrite links with overwrite option set to a function that returns false', function(done) { + var existingContents = 'Lorem Ipsum'; + + var file = new File({ + base: inputBase, + path: inputPath, + contents: null, + }); + + // `src()` adds this side-effect with `resolveSymlinks` option set to false + file.symlink = inputPath; + + function overwrite(f) { + expect(f).toEqual(file); + return false; + } + + function assert(files) { + var outputContents = fs.readFileSync(outputPath, 'utf8'); + + expect(files.length).toEqual(1); + expect(outputContents).toEqual(existingContents); + } + + // Write expected file which should not be overwritten + fs.mkdirSync(outputBase); + fs.writeFileSync(outputPath, existingContents); + + pipe([ + from.obj([file]), + vfs.dest(outputBase, { overwrite: overwrite }), + concat(assert), + ], done); + }); + + it('overwrites links with overwrite option set to a function that returns true', function(done) { + var existingContents = 'Lorem Ipsum'; + + var file = new File({ + base: inputBase, + path: inputPath, + contents: null, + }); + + // `src()` adds this side-effect with `resolveSymlinks` option set to false + file.symlink = inputPath; + + function overwrite(f) { + expect(f).toEqual(file); + return true; + } + + function assert(files) { + var outputContents = fs.readFileSync(outputPath, 'utf8'); + + expect(files.length).toEqual(1); + expect(outputContents).toEqual(contents); + } + + // This should be overwritten + fs.mkdirSync(outputBase); + fs.writeFileSync(outputPath, existingContents); + + pipe([ + from.obj([file]), + vfs.dest(outputBase, { overwrite: overwrite }), + concat(assert), + ], done); + }); +}); diff --git a/test/dest.js b/test/dest.js index e301a2f2..e92dc569 100644 --- a/test/dest.js +++ b/test/dest.js @@ -606,34 +606,6 @@ describe('.dest()', function() { ], done); }); - it('creates symlinks when the `symlink` attribute is set on the file', function(done) { - var inputRelativeSymlinkPath = 'wow'; - - var file = new File({ - base: inputBase, - path: inputDirpath, - contents: null, - }); - - // `src()` adds this side-effect with `resolveSymlinks` option set to false - file.symlink = inputRelativeSymlinkPath; - - function assert(files) { - var symlink = fs.readlinkSync(outputDirpath); - - expect(files.length).toEqual(1); - expect(file.symlink).toEqual(symlink); - expect(files[0].symlink).toEqual(symlink); - expect(files[0].path).toEqual(outputDirpath); - } - - pipe([ - from.obj([file]), - vfs.dest(outputBase), - concat(assert), - ], done); - }); - it('emits a finish event', function(done) { var destStream = vfs.dest(outputBase);