From d919136160f5e6d87eafb6891a0ac677d21e0c85 Mon Sep 17 00:00:00 2001 From: Danny McCormick Date: Tue, 9 Jul 2019 09:56:01 -0400 Subject: [PATCH] Fix cp and mv (#26) --- packages/io/__tests__/io.test.ts | 79 +++++------ packages/io/src/io-util.ts | 4 + packages/io/src/io.ts | 216 ++++++++++++++++++------------- 3 files changed, 173 insertions(+), 126 deletions(-) diff --git a/packages/io/__tests__/io.test.ts b/packages/io/__tests__/io.test.ts index 045bb4dd..1c6aea87 100644 --- a/packages/io/__tests__/io.test.ts +++ b/packages/io/__tests__/io.test.ts @@ -59,13 +59,7 @@ describe('cp', () => { await io.mkdirP(root) await fs.writeFile(sourceFile, 'test file content', {encoding: 'utf8'}) await fs.writeFile(targetFile, 'correct content', {encoding: 'utf8'}) - let failed = false - try { - await io.cp(sourceFile, targetFile, {recursive: false, force: false}) - } catch { - failed = true - } - expect(failed).toBe(true) + await io.cp(sourceFile, targetFile, {recursive: false, force: false}) expect(await fs.readFile(targetFile, {encoding: 'utf8'})).toBe( 'correct content' @@ -132,6 +126,43 @@ describe('cp', () => { expect(thrown).toBe(true) await assertNotExists(targetFile) }) + + it('Copies symlinks correctly', async () => { + // create the following layout + // sourceFolder + // sourceFolder/nested + // sourceFolder/nested/sourceFile + // sourceFolder/symlinkDirectory -> sourceFile + const root: string = path.join(getTestTemp(), 'cp_with_-r_symlinks') + const sourceFolder: string = path.join(root, 'cp_source') + const nestedFolder: string = path.join(sourceFolder, 'nested') + const sourceFile: string = path.join(nestedFolder, 'cp_source_file') + const symlinkDirectory: string = path.join(sourceFolder, 'symlinkDirectory') + + const targetFolder: string = path.join(root, 'cp_target') + const targetFile: string = path.join( + targetFolder, + 'nested', + 'cp_source_file' + ) + const symlinkTargetPath: string = path.join( + targetFolder, + 'symlinkDirectory', + 'cp_source_file' + ) + await io.mkdirP(sourceFolder) + await io.mkdirP(nestedFolder) + await fs.writeFile(sourceFile, 'test file content', {encoding: 'utf8'}) + await createSymlinkDir(nestedFolder, symlinkDirectory) + await io.cp(sourceFolder, targetFolder, {recursive: true}) + + expect(await fs.readFile(targetFile, {encoding: 'utf8'})).toBe( + 'test file content' + ) + expect(await fs.readFile(symlinkTargetPath, {encoding: 'utf8'})).toBe( + 'test file content' + ) + }) }) describe('mv', () => { @@ -189,7 +220,7 @@ describe('mv', () => { ) }) - it('moves directory into existing destination with -r', async () => { + it('moves directory into existing destination', async () => { const root: string = path.join(getTestTemp(), ' mv_with_-r_existing_dest') const sourceFolder: string = path.join(root, ' mv_source') const sourceFile: string = path.join(sourceFolder, ' mv_source_file') @@ -203,7 +234,7 @@ describe('mv', () => { await io.mkdirP(sourceFolder) await fs.writeFile(sourceFile, 'test file content', {encoding: 'utf8'}) await io.mkdirP(targetFolder) - await io.mv(sourceFolder, targetFolder, {recursive: true}) + await io.mv(sourceFolder, targetFolder) expect(await fs.readFile(targetFile, {encoding: 'utf8'})).toBe( 'test file content' @@ -211,7 +242,7 @@ describe('mv', () => { await assertNotExists(sourceFile) }) - it('moves directory into non-existing destination with -r', async () => { + it('moves directory into non-existing destination', async () => { const root: string = path.join( getTestTemp(), ' mv_with_-r_nonexisting_dest' @@ -223,39 +254,13 @@ describe('mv', () => { const targetFile: string = path.join(targetFolder, ' mv_source_file') await io.mkdirP(sourceFolder) await fs.writeFile(sourceFile, 'test file content', {encoding: 'utf8'}) - await io.mv(sourceFolder, targetFolder, {recursive: true}) + await io.mv(sourceFolder, targetFolder) expect(await fs.readFile(targetFile, {encoding: 'utf8'})).toBe( 'test file content' ) await assertNotExists(sourceFile) }) - - it('tries to move directory without -r', async () => { - const root: string = path.join(getTestTemp(), 'mv_without_-r') - const sourceFolder: string = path.join(root, 'mv_source') - const sourceFile: string = path.join(sourceFolder, 'mv_source_file') - - const targetFolder: string = path.join(root, 'mv_target') - const targetFile: string = path.join( - targetFolder, - 'mv_source', - 'mv_source_file' - ) - await io.mkdirP(sourceFolder) - await fs.writeFile(sourceFile, 'test file content', {encoding: 'utf8'}) - - let thrown = false - try { - await io.mv(sourceFolder, targetFolder) - } catch (err) { - thrown = true - } - - expect(thrown).toBe(true) - await assertExists(sourceFile) - await assertNotExists(targetFile) - }) }) describe('rmRF', () => { diff --git a/packages/io/src/io-util.ts b/packages/io/src/io-util.ts index d5d4e677..f9775217 100644 --- a/packages/io/src/io-util.ts +++ b/packages/io/src/io-util.ts @@ -3,12 +3,16 @@ import * as fs from 'fs' import * as path from 'path' export const { + chmod, copyFile, lstat, mkdir, readdir, + readlink, + rename, rmdir, stat, + symlink, unlink } = fs.promises diff --git a/packages/io/src/io.ts b/packages/io/src/io.ts index 17740cda..dc263134 100644 --- a/packages/io/src/io.ts +++ b/packages/io/src/io.ts @@ -1,5 +1,4 @@ import * as childProcess from 'child_process' -import * as fs from 'fs' import * as path from 'path' import {promisify} from 'util' import * as ioUtil from './io-util' @@ -16,8 +15,17 @@ export interface CopyOptions { force?: boolean } +/** + * Interface for cp/mv options + */ +export interface MoveOptions { + /** Optional. Whether to overwrite existing files in the destination. Defaults to true */ + force?: boolean +} + /** * Copies a file or folder. + * Based off of shelljs - https://github.com/shelljs/shelljs/blob/9237f66c52e5daa40458f94f9565e18e8132f5a6/src/cp.js * * @param source source path * @param dest destination path @@ -28,7 +36,41 @@ export async function cp( dest: string, options: CopyOptions = {} ): Promise { - await move(source, dest, options, {deleteOriginal: false}) + const {force, recursive} = readCopyOptions(options) + + const destStat = (await ioUtil.exists(dest)) ? await ioUtil.stat(dest) : null + // Dest is an existing file, but not forcing + if (destStat && destStat.isFile() && !force) { + return + } + + // If dest is an existing directory, should copy inside. + const newDest: string = + destStat && destStat.isDirectory() + ? path.join(dest, path.basename(source)) + : dest + + if (!(await ioUtil.exists(source))) { + throw new Error(`no such file or directory: ${source}`) + } + const sourceStat = await ioUtil.stat(source) + + if (sourceStat.isDirectory()) { + if (!recursive) { + throw new Error( + `Failed to copy. ${source} is a directory, but tried to copy without recursive flag.` + ) + } else { + await cpDirRecursive(source, newDest, 0, force) + } + } else { + if (path.relative(source, newDest) === '') { + // a file cannot be copied to itself + throw new Error(`'${newDest}' and '${source}' are the same file`) + } + + await copyFile(source, newDest, force) + } } /** @@ -36,14 +78,31 @@ export async function cp( * * @param source source path * @param dest destination path - * @param options optional. See CopyOptions. + * @param options optional. See MoveOptions. */ export async function mv( source: string, dest: string, - options: CopyOptions = {} + options: MoveOptions = {} ): Promise { - await move(source, dest, options, {deleteOriginal: true}) + if (await ioUtil.exists(dest)) { + let destExists = true + if (await ioUtil.isDirectory(dest)) { + // If dest is directory copy src into dest + dest = path.join(dest, path.basename(source)) + destExists = await ioUtil.exists(dest) + } + + if (destExists) { + if (options.force == null || options.force) { + await rmRF(dest) + } else { + throw new Error('Destination already exists') + } + } + } + await mkdirP(path.dirname(dest)) + await ioUtil.rename(source, dest) } /** @@ -198,92 +257,71 @@ export async function which(tool: string, check?: boolean): Promise { } } -// Copies contents of source into dest, making any necessary folders along the way. -// Deletes the original copy if deleteOriginal is true -async function copyDirectoryContents( - source: string, - dest: string, - force: boolean, - deleteOriginal = false -): Promise { - if (await ioUtil.isDirectory(source)) { - if (await ioUtil.exists(dest)) { - if (!(await ioUtil.isDirectory(dest))) { - throw new Error(`${dest} is not a directory`) - } - } else { - await mkdirP(dest) - } - - // Copy all child files, and directories recursively - const sourceChildren: string[] = await ioUtil.readdir(source) - - for (const newSource of sourceChildren) { - const newDest = path.join(dest, path.basename(newSource)) - await copyDirectoryContents( - path.resolve(source, newSource), - newDest, - force, - deleteOriginal - ) - } - - if (deleteOriginal) { - await ioUtil.rmdir(source) - } - } else { - if (force) { - await ioUtil.copyFile(source, dest) - } else { - await ioUtil.copyFile(source, dest, fs.constants.COPYFILE_EXCL) - } - if (deleteOriginal) { - await ioUtil.unlink(source) - } - } -} - -async function move( - source: string, - dest: string, - options: CopyOptions = {}, - moveOptions: {deleteOriginal: boolean} -): Promise { - const {force, recursive} = readCopyOptions(options) - - if (await ioUtil.isDirectory(source)) { - if (!recursive) { - throw new Error(`non-recursive cp failed, ${source} is a directory`) - } - - // If directory exists, move source inside it. Otherwise, create it and move contents of source inside. - if (await ioUtil.exists(dest)) { - if (!(await ioUtil.isDirectory(dest))) { - throw new Error(`${dest} is not a directory`) - } - - dest = path.join(dest, path.basename(source)) - } - - await copyDirectoryContents(source, dest, force, moveOptions.deleteOriginal) - } else { - if ((await ioUtil.exists(dest)) && (await ioUtil.isDirectory(dest))) { - dest = path.join(dest, path.basename(source)) - } - if (force) { - await ioUtil.copyFile(source, dest) - } else { - await ioUtil.copyFile(source, dest, fs.constants.COPYFILE_EXCL) - } - - if (moveOptions.deleteOriginal) { - await ioUtil.unlink(source) - } - } -} - function readCopyOptions(options: CopyOptions): Required { const force = options.force == null ? true : options.force const recursive = Boolean(options.recursive) return {force, recursive} } + +async function cpDirRecursive( + sourceDir: string, + destDir: string, + currentDepth: number, + force: boolean +): Promise { + // Ensure there is not a run away recursive copy + if (currentDepth >= 255) return + currentDepth++ + + await mkdirP(destDir) + + const files: string[] = await ioUtil.readdir(sourceDir) + + for (const fileName of files) { + const srcFile = `${sourceDir}/${fileName}` + const destFile = `${destDir}/${fileName}` + const srcFileStat = await ioUtil.lstat(srcFile) + + if (srcFileStat.isDirectory()) { + // Recurse + await cpDirRecursive(srcFile, destFile, currentDepth, force) + } else { + await copyFile(srcFile, destFile, force) + } + } + + // Change the mode for the newly created directory + await ioUtil.chmod(destDir, (await ioUtil.stat(sourceDir)).mode) +} + +// Buffered file copy +async function copyFile( + srcFile: string, + destFile: string, + force: boolean +): Promise { + if ((await ioUtil.lstat(srcFile)).isSymbolicLink()) { + // unlink/re-link it + try { + await ioUtil.lstat(destFile) + await ioUtil.unlink(destFile) + } catch (e) { + // Try to override file permission + if (e.code === 'EPERM') { + await ioUtil.chmod(destFile, '0666') + await ioUtil.unlink(destFile) + } + // other errors = it doesn't exist, no work to do + } + + // Copy over symlink + const symlinkFull: string = await ioUtil.readlink(srcFile) + await ioUtil.symlink( + symlinkFull, + destFile, + ioUtil.IS_WINDOWS ? 'junction' : null + ) + } else if (!(await ioUtil.exists(destFile)) || force) { + await ioUtil.copyFile(srcFile, destFile) + } +}