Skip to content

Commit

Permalink
feat: support force, dereference and preserveTimestamps
Browse files Browse the repository at this point in the history
  • Loading branch information
SukkaW committed Oct 22, 2023
1 parent dc866d5 commit 489e421
Show file tree
Hide file tree
Showing 3 changed files with 110 additions and 16 deletions.
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,19 +38,20 @@ import { copy } from 'rcpy'; // "copy" is an alias of "rcpy"
- `src`: `string` the path of the file/directory to copy. Note that if `src` is a directory it will copy everything inside of this directory, not the entire directory itself.
- `dest`: `string` the destination of the copied file/directory. Note that currently if `src` is a file, `dest` cannot be a directory. This behavior might be changed in the future.
- `option`: `RcpyOption` optional.
- `overwrite`: `boolean` optional. whether to overwrite existing file/directory, default to `true`. Note that the copy operation will silently fail if you set this to false and the destination exists. Use the `errorOnExist` option to change this behavior.
- `dereference`: `boolean` optional. whether to dereference symbolic links, default to `false`.
- `force`: `boolean` optional. whether to overwrite existing file/directory, default to `true`. Note that the copy operation will silently fail if you set this to false and the destination exists. Use the `errorOnExist` option to change this behavior.
- `overwrite`: `boolean` optional. The alias of `force`, serves as a compatibility option for `fs-extra`.
- `errorOnExist`: `boolean` optional. whether to throw an error if `dest` already exists, default to `false`.
- `filter`: `(src: string, dest: string) => boolean | Promise<boolean>` optional. filter copied files/directories, return `true` to copy, `false` to skip.
- `preserveTimestamps`: `boolean` optional. whether to preserve file timestamps, default to `false`, where the behavior is OS-dependent.
- `concurrency`: `number` optional. the number of concurrent copy operations, default to `32`.


## Differences between `rcpy` and `fs-extra`

- Doesn't use `graceful-fs` to prevent `EMFILE` error.
- `rcpy` instead provides a `concurrency` option to limit the number of concurrent copy operations.
- Asynchronous and Promise-based API only. No synchronous API, no Node.js callback style API.
- Doesn't support `preserveTimestamps` option. The timestamp behavior is OS-dependent.
- Doesn't support `dereference` option. Symbolic links are always dereferenced.
- Use `require('util').callbackify` to convert `rcpy` to Node.js callback style API.P

## License

Expand Down
71 changes: 65 additions & 6 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,67 @@
import path from 'path';
import fs from 'fs';
import fsp from 'fs/promises';
import { checkParentPaths, checkPaths, isSrcSubdir } from './util';
import { checkParentPaths, checkPaths, isSrcSubdir, utimesMillis } from './util';
import process from 'process';
import { Sema } from 'async-sema';

// const COPYFILE_EXCL = fs.constants.COPYFILE_EXCL;

type FilterFn = (src: string, dest: string) => boolean | Promise<boolean>;

export interface RcpyOption {
/** @default false */
dereference?: boolean,
filter?: FilterFn,
/** @default true */
force?: boolean,
/**
* @deprecated
* @default true
*/
overwrite?: boolean,
/** @default false */
preserveTimestamps?: boolean,
/** @default false */
errorOnExist?: boolean,
/** @default 32 */
concurrency?: number
}

const rcpy = async (src: string, dest: string, opt: RcpyOption = {}): Promise<void> => {
const _opt: Required<RcpyOption> = Object.assign({
dereference: false,
filter: (_src: string, _dest: string) => true,
force: opt.overwrite ?? true,
overwrite: true,
errorOnExist: false,
preserveTimestamps: false,
concurrency: 32
}, opt);
const filter = _opt.filter;

// Warn about using preserveTimestamps on 32-bit node
if (_opt.preserveTimestamps && process.arch === 'ia32') {
process.emitWarning(
'Using the preserveTimestamps option in 32-bit node is not recommended;\n\n'
+ '\tsee https://github.com/jprichardson/node-fs-extra/issues/269',
'Warning', 'rcpy-WARN0001'
);
}

if (!(await filter(src, dest))) {
return;
}

const sema = new Sema(_opt.concurrency);

const checkResult = await checkPaths(src, dest);
const checkResult = await checkPaths(src, dest, _opt.dereference);
const srcStat = checkResult[0];

const destParent = path.resolve(path.dirname(dest));
const destParentExists = fs.existsSync(destParent);

await checkParentPaths(src, srcStat, dest);
await checkParentPaths(src, srcStat, dest, _opt.dereference);

if (!destParentExists) {
await fsp.mkdir(destParent, { recursive: true });
Expand Down Expand Up @@ -68,13 +93,31 @@ const rcpy = async (src: string, dest: string, opt: RcpyOption = {}): Promise<vo

async function copyFile(srcStat: fs.Stats, src: string, dest: string) {
await fsp.copyFile(src, dest);

if (_opt.preserveTimestamps) {
// Make sure the file is writable before setting the timestamp
// otherwise open fails with EPERM when invoked with 'r+'
// (through utimes call)
if (fileIsNotWritable(srcStat.mode)) {
await makeFileWritable(dest, srcStat.mode);
}

// Set timestamps and mode correspondingly

// Note that The initial srcStat.atime cannot be trusted
// because it is modified by the read(2) system call
// (See https://nodejs.org/api/fs.html#fs_stat_time_values)
const updatedSrcStat = await fsp.stat(src);
await utimesMillis(dest, updatedSrcStat.atime, updatedSrcStat.mtime);
}

return fsp.chmod(dest, srcStat.mode);
}

async function onFile(srcStat: fs.Stats, destStat: fs.Stats | null, src: string, dest: string) {
if (!destStat) return copyFile(srcStat, src, dest);

if (_opt.overwrite) {
if (_opt.force) {
await fsp.unlink(dest);
return copyFile(srcStat, src, dest);
}
Expand All @@ -99,7 +142,7 @@ const rcpy = async (src: string, dest: string, opt: RcpyOption = {}): Promise<vo
return;
}

return performCopy(srcItem, destItem, await checkPaths(srcItem, destItem));
return performCopy(srcItem, destItem, await checkPaths(srcItem, destItem, _opt.dereference));
}));

if (!destStat) {
Expand All @@ -108,7 +151,11 @@ const rcpy = async (src: string, dest: string, opt: RcpyOption = {}): Promise<vo
}

async function onLink(src: string, dest: string, destStat: fs.Stats | null) {
const resolvedSrc = await fsp.readlink(src);
let resolvedSrc = await fsp.readlink(src);

if (_opt.dereference) {
resolvedSrc = path.resolve(process.cwd(), resolvedSrc);
}

if (!destStat) {
return fsp.symlink(resolvedSrc, dest);
Expand All @@ -126,6 +173,10 @@ const rcpy = async (src: string, dest: string, opt: RcpyOption = {}): Promise<vo
throw e;
}

if (_opt.dereference) {
resolvedDest = path.resolve(process.cwd(), resolvedDest);
}

if (isSrcSubdir(resolvedSrc, resolvedDest)) {
throw new Error(`Cannot copy '${resolvedSrc}' to a subdirectory of itself, '${resolvedDest}'.`);
}
Expand All @@ -143,4 +194,12 @@ const rcpy = async (src: string, dest: string, opt: RcpyOption = {}): Promise<vo
return performCopy(src, dest, checkResult);
};

function fileIsNotWritable(srcMode: number) {
return (srcMode & 0o200) === 0;
}

function makeFileWritable(dest: string, srcMode: number) {
return fsp.chmod(dest, srcMode | 0o200);
}

export { rcpy, rcpy as copy };
46 changes: 40 additions & 6 deletions src/util.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import fs from 'fs';
import fsp from 'fs/promises';
import path from 'path';
import { promisify } from 'util';

const futimes = promisify(fs.futimes);
const open = promisify(fs.open);
const close = promisify(fs.close);

export const areIdentical = (srcStat: fs.Stats, destStat: fs.Stats) => destStat.ino && destStat.dev && destStat.ino === srcStat.ino && destStat.dev === srcStat.dev;

Expand All @@ -14,9 +19,10 @@ export function isSrcSubdir(src: string, dest: string) {
return dest.startsWith(src);
}

export async function checkPaths(src: string, dest: string) {
const srcStat = await fsp.lstat(src);
const destStat: fs.Stats | null = fs.existsSync(dest) ? await fsp.lstat(dest) : null;
export async function checkPaths(src: string, dest: string, dereference: boolean) {
const statFn = dereference ? fsp.lstat : fsp.stat;
const srcStat = await statFn(src);
const destStat: fs.Stats | null = fs.existsSync(dest) ? await statFn(dest) : null;

const srcIsDir = srcStat.isDirectory();

Expand Down Expand Up @@ -44,17 +50,45 @@ export async function checkPaths(src: string, dest: string) {
// It works for all file types including symlinks since it
// checks the src and dest inodes. It starts from the deepest
// parent and stops once it reaches the src parent or the root path.
export async function checkParentPaths(src: string, srcStat: fs.Stats, dest: string) {
export async function checkParentPaths(src: string, srcStat: fs.Stats, dest: string, dereference: boolean) {
const srcParent = path.resolve(path.dirname(src));
const destParent = path.resolve(path.dirname(dest));
if (destParent === srcParent || destParent === path.parse(destParent).root) return;

if (!fs.existsSync(destParent)) return;

const destParentStat = await fsp.lstat(destParent);
const statFn = dereference ? fsp.lstat : fsp.stat;
const destParentStat = await statFn(destParent);
if (areIdentical(srcStat, destParentStat)) {
throw new Error(`Cannot copy '${src}' to a subdirectory of itself, '${dest}'.`);
}

return checkParentPaths(src, srcStat, destParent);
return checkParentPaths(src, srcStat, destParent, dereference);
}

export async function utimesMillis(path: string, atime: fs.TimeLike, mtime: fs.TimeLike) {
// if (!HAS_MILLIS_RES) return fs.utimes(path, atime, mtime, callback)
const fd = await open(path, 'r+');

let futimesErr = null;
try {
await futimes(fd, atime, mtime);
} catch (e) {
futimesErr = e;
}

let closeErr = null;

try {
await close(fd);
} catch (e) {
closeErr = e;
}

if (futimesErr) {
throw futimesErr;
}
if (closeErr) {
throw closeErr;
}
}

0 comments on commit 489e421

Please sign in to comment.