Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update dest atime after copy/copy-sync #633

Merged
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
50 changes: 32 additions & 18 deletions lib/copy-sync/__tests__/copy-sync-preserve-timestamp.test.js
Expand Up @@ -3,7 +3,8 @@
const fs = require('../../')
const os = require('os')
const path = require('path')
const utimes = require('../../util/utimes')
const copySync = require('../copy-sync')
const utimesSync = require('../../util/utimes').utimesMillisSync
const assert = require('assert')
const semver = require('semver')
const nodeVersion = process.version
Expand All @@ -19,21 +20,39 @@ const describeIfPractical = (process.arch === 'ia32' || nodeVersionMajor < 8) ?
describeIfPractical('copySync() - preserveTimestamps option', () => {
let TEST_DIR, SRC, DEST, FILES

beforeEach(done => {
TEST_DIR = path.join(os.tmpdir(), 'fs-extra', 'copy-sync-preserve-time')
function setupFixture (readonly) {
TEST_DIR = path.join(os.tmpdir(), 'fs-extra', 'copy-sync-preserve-timestamp')
SRC = path.join(TEST_DIR, 'src')
DEST = path.join(TEST_DIR, 'dest')
FILES = ['a-file', path.join('a-folder', 'another-file'), path.join('a-folder', 'another-folder', 'file3')]
FILES.forEach(f => fs.ensureFileSync(path.join(SRC, f)))
done()
})
const timestamp = Date.now() / 1000 - 5
FILES.forEach(f => {
const filePath = path.join(SRC, f)
fs.ensureFileSync(filePath)
// rewind timestamps to make sure that coarser OS timestamp resolution
// does not alter results
utimesSync(filePath, timestamp, timestamp)
if (readonly) {
fs.chmodSync(filePath, 0o444)
}
})
}

afterEach(done => fs.remove(TEST_DIR, done))

describe('> when preserveTimestamps option is true', () => {
it('should have the same timestamps on copy', () => {
fs.copySync(SRC, DEST, { preserveTimestamps: true })
FILES.forEach(testFile({ preserveTimestamps: true }))
;[
{ subcase: 'writable', readonly: false },
{ subcase: 'readonly', readonly: true }
].forEach(params => {
describe(`>> with ${params.subcase} source files`, () => {
beforeEach(() => setupFixture(params.readonly))

it('should have the same timestamps on copy', () => {
copySync(SRC, DEST, { preserveTimestamps: true })
FILES.forEach(testFile({ preserveTimestamps: true }))
})
})
})
})

Expand All @@ -44,16 +63,11 @@ describeIfPractical('copySync() - preserveTimestamps option', () => {
const fromStat = fs.statSync(a)
const toStat = fs.statSync(b)
if (options.preserveTimestamps) {
// https://github.com/nodejs/io.js/issues/2069
if (process.platform !== 'win32') {
assert.strictEqual(toStat.mtime.getTime(), fromStat.mtime.getTime())
assert.strictEqual(toStat.atime.getTime(), fromStat.atime.getTime())
} else {
assert.strictEqual(toStat.mtime.getTime(), utimes.timeRemoveMillis(fromStat.mtime.getTime()))
assert.strictEqual(toStat.atime.getTime(), utimes.timeRemoveMillis(fromStat.atime.getTime()))
}
// Windows sub-second precision fixed: https://github.com/nodejs/io.js/issues/2069
assert.strictEqual(toStat.mtime.getTime(), fromStat.mtime.getTime(), 'different mtime values')
assert.strictEqual(toStat.atime.getTime(), fromStat.atime.getTime(), 'different atime values')
} else {
assert.notStrictEqual(toStat.mtime.getTime(), fromStat.mtime.getTime())
assert.notStrictEqual(toStat.mtime.getTime(), fromStat.mtime.getTime(), 'same mtime values')
// the access time might actually be the same, so check only modification time
}
}
Expand Down
28 changes: 21 additions & 7 deletions lib/copy-sync/copy-sync.js
Expand Up @@ -3,7 +3,7 @@
const fs = require('graceful-fs')
const path = require('path')
const mkdirpSync = require('../mkdirs').mkdirsSync
const utimesSync = require('../util/utimes.js').utimesMillisSync
const utimesMillisSync = require('../util/utimes').utimesMillisSync
const stat = require('../util/stat')

function copySync (src, dest, opts) {
Expand Down Expand Up @@ -66,11 +66,13 @@ function mayCopyFile (srcStat, src, dest, opts) {
function copyFile (srcStat, src, dest, opts) {
if (typeof fs.copyFileSync === 'function') {
fs.copyFileSync(src, dest)
fs.chmodSync(dest, srcStat.mode)
if (opts.preserveTimestamps) {
return utimesSync(dest, srcStat.atime, srcStat.mtime)
if (opts.preserveTimestamps && (srcStat.mode & 0o200) === 0) {
// Make sure the file is writable before setting the timestamp
// otherwise openSync fails with EPERM when invoked with 'r+'
// (through utimes call)
fs.chmodSync(dest, srcStat.mode | 0o200)
}
return
return setDestTimestampsAndMode(srcStat, src, dest, opts)
}
return copyFileFallback(srcStat, src, dest, opts)
}
Expand All @@ -80,7 +82,7 @@ function copyFileFallback (srcStat, src, dest, opts) {
const _buff = require('../util/buffer')(BUF_LENGTH)

const fdr = fs.openSync(src, 'r')
const fdw = fs.openSync(dest, 'w', srcStat.mode)
const fdw = fs.openSync(dest, 'w')
let pos = 0

while (pos < srcStat.size) {
Expand All @@ -89,12 +91,24 @@ function copyFileFallback (srcStat, src, dest, opts) {
pos += bytesRead
}

if (opts.preserveTimestamps) fs.futimesSync(fdw, srcStat.atime, srcStat.mtime)
setDestTimestampsAndMode(srcStat, src, fdw, opts)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also I just realized we forgot to chmod again here before closing fds. copy does that and we need to be consistent. So I think it is better we have the same function setDestModeAndTimestamps() like in copy so that we can call it after both native copy and fallback.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That would make sense. I'll try it out.

Copy link
Contributor Author

@mbargiel mbargiel May 26, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question, though: if the fallback copy is used, wouldn't it be much better to use the futimes and fchmod operations directly to make the operation both atomic and faster, rather than closing the file descriptor and calling the path-based setDestModeAndTimestamps() function?

In fact, the setDestModeAndTimestamps function could be split in two, one taking paths in, and the other taking file descriptors in. Something like:

  • copyFile -> fs.copy -> setDestModeAndTimestamps(src, dest...) -> setDestModeAndTimestampsFd(fsd, fwd...)
  • copyFileFallback -> fs.open -> (copy) -> setDestModeAndTimestampsFd(fsd, fwd...) -> fs.close

What do you think?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure the latter works for async since the async copyFileFallback uses streams with implicit FDs. But it seems a good solution for the sync version.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@manidlou What do you think of f55125e?

I also added tests for copyFileFallback in 1a6eacb - perhaps in a rather unorthodox way, but I quickly figured that we should basically ensure feature parity between both implementations, so why not leverage the same tests...?

fs.closeSync(fdr)
fs.closeSync(fdw)
}

function setDestTimestampsAndMode (srcStat, src, dest, opts) {
const utimesSync = typeof dest === 'string' ? utimesMillisSync : fs.futimesSync
const chmodSync = typeof dest === 'string' ? fs.chmodSync : fs.fchmodSync
if (opts.preserveTimestamps) {
// 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 = fs.statSync(src)
utimesSync(dest, updatedSrcStat.atime, updatedSrcStat.mtime)
}
chmodSync(dest, srcStat.mode)
}

function onDir (srcStat, destStat, src, dest, opts) {
if (!destStat) return mkDirAndCopy(srcStat, src, dest, opts)
if (destStat && !destStat.isDirectory()) {
Expand Down
52 changes: 33 additions & 19 deletions lib/copy/__tests__/copy-preserve-timestamp.test.js
Expand Up @@ -4,7 +4,7 @@ const fs = require('../../')
const os = require('os')
const path = require('path')
const copy = require('../copy')
const utimes = require('../../util/utimes')
const utimesSync = require('../../util/utimes').utimesMillisSync
const assert = require('assert')
const semver = require('semver')
const nodeVersion = process.version
Expand All @@ -20,22 +20,41 @@ const describeIfPractical = (process.arch === 'ia32' || nodeVersionMajor < 8) ?
describeIfPractical('copy() - preserve timestamp', () => {
let TEST_DIR, SRC, DEST, FILES

beforeEach(done => {
function setupFixture (readonly) {
TEST_DIR = path.join(os.tmpdir(), 'fs-extra', 'copy-preserve-timestamp')
SRC = path.join(TEST_DIR, 'src')
DEST = path.join(TEST_DIR, 'dest')
FILES = ['a-file', path.join('a-folder', 'another-file'), path.join('a-folder', 'another-folder', 'file3')]
FILES.forEach(f => fs.ensureFileSync(path.join(SRC, f)))
done()
})
const timestamp = Date.now() / 1000 - 5
FILES.forEach(f => {
const filePath = path.join(SRC, f)
fs.ensureFileSync(filePath)
// rewind timestamps to make sure that coarser OS timestamp resolution
// does not alter results
utimesSync(filePath, timestamp, timestamp)
if (readonly) {
fs.chmodSync(filePath, 0o444)
}
})
}

afterEach(done => fs.remove(TEST_DIR, done))

describe('> when timestamp option is true', () => {
it('should have the same timestamps on copy', done => {
copy(SRC, DEST, { preserveTimestamps: true }, () => {
FILES.forEach(testFile({ preserveTimestamps: true }))
done()
describe('> when preserveTimestamps option is true', () => {
;[
{ subcase: 'writable', readonly: false },
{ subcase: 'readonly', readonly: true }
].forEach(params => {
describe(`>> with ${params.subcase} source files`, () => {
beforeEach(() => setupFixture(params.readonly))

it('should have the same timestamps on copy', done => {
copy(SRC, DEST, { preserveTimestamps: true }, (err) => {
if (err) return done(err)
FILES.forEach(testFile({ preserveTimestamps: true }))
done()
})
})
})
})
})
Expand All @@ -47,16 +66,11 @@ describeIfPractical('copy() - preserve timestamp', () => {
const fromStat = fs.statSync(a)
const toStat = fs.statSync(b)
if (options.preserveTimestamps) {
// https://github.com/nodejs/io.js/issues/2069
if (process.platform !== 'win32') {
assert.strictEqual(toStat.mtime.getTime(), fromStat.mtime.getTime())
assert.strictEqual(toStat.atime.getTime(), fromStat.atime.getTime())
} else {
assert.strictEqual(toStat.mtime.getTime(), utimes.timeRemoveMillis(fromStat.mtime.getTime()))
assert.strictEqual(toStat.atime.getTime(), utimes.timeRemoveMillis(fromStat.atime.getTime()))
}
// Windows sub-second precision fixed: https://github.com/nodejs/io.js/issues/2069
assert.strictEqual(toStat.mtime.getTime(), fromStat.mtime.getTime(), 'different mtime values')
assert.strictEqual(toStat.atime.getTime(), fromStat.atime.getTime(), 'different atime values')
} else {
assert.notStrictEqual(toStat.mtime.getTime(), fromStat.mtime.getTime())
assert.notStrictEqual(toStat.mtime.getTime(), fromStat.mtime.getTime(), 'same mtime values')
// the access time might actually be the same, so check only modification time
}
}
Expand Down
39 changes: 27 additions & 12 deletions lib/copy/copy.js
Expand Up @@ -4,7 +4,7 @@ const fs = require('graceful-fs')
const path = require('path')
const mkdirp = require('../mkdirs').mkdirs
const pathExists = require('../path-exists').pathExists
const utimes = require('../util/utimes').utimesMillis
const utimesMillis = require('../util/utimes').utimesMillis
const stat = require('../util/stat')

function copy (src, dest, opts, cb) {
Expand Down Expand Up @@ -95,7 +95,16 @@ function copyFile (srcStat, src, dest, opts, cb) {
if (typeof fs.copyFile === 'function') {
return fs.copyFile(src, dest, err => {
if (err) return cb(err)
return setDestModeAndTimestamps(srcStat, dest, opts, cb)
if (opts.preserveTimestamps && (srcStat.mode & 0o200) === 0) {
// Make sure the file is writable before setting the timestamp
// otherwise openSync fails with EPERM when invoked with 'r+'
// (through utimes call)
return fs.chmod(dest, srcStat.mode | 0o200, (err) => {
if (err) return cb(err)
return setDestTimestampsAndMode(srcStat, src, dest, opts, cb)
})
}
return setDestTimestampsAndMode(srcStat, src, dest, opts, cb)
})
}
return copyFileFallback(srcStat, src, dest, opts, cb)
Expand All @@ -104,21 +113,27 @@ function copyFile (srcStat, src, dest, opts, cb) {
function copyFileFallback (srcStat, src, dest, opts, cb) {
const rs = fs.createReadStream(src)
rs.on('error', err => cb(err)).once('open', () => {
const ws = fs.createWriteStream(dest, { mode: srcStat.mode })
// explicitly not setting srcStat mode - will do that last
const ws = fs.createWriteStream(dest)
ws.on('error', err => cb(err))
.on('open', () => rs.pipe(ws))
.once('close', () => setDestModeAndTimestamps(srcStat, dest, opts, cb))
.once('close', () => setDestTimestampsAndMode(srcStat, src, dest, opts, cb))
})
}

function setDestModeAndTimestamps (srcStat, dest, opts, cb) {
fs.chmod(dest, srcStat.mode, err => {
if (err) return cb(err)
if (opts.preserveTimestamps) {
return utimes(dest, srcStat.atime, srcStat.mtime, cb)
}
return cb()
})
function setDestTimestampsAndMode (srcStat, src, dest, opts, cb) {
if (opts.preserveTimestamps) {
// 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)
return fs.stat(src, (err, updatedSrcStat) => {
if (err) return cb(err)
return utimesMillis(dest, updatedSrcStat.atime, updatedSrcStat.mtime, (err2) => {
if (err2) return cb(err2)
return fs.chmod(dest, srcStat.mode, cb)
})
})
}
return fs.chmod(dest, srcStat.mode, cb)
}

function onDir (srcStat, destStat, src, dest, opts, cb) {
Expand Down