Add EPERM copy fallback

This commit is contained in:
Nicholas Wallace committed 2026-10-20 21:39:26 -07:00
1 parent 743d915220
commit 16aa80ed5c
3 files changed
+138 -2

No files matched your search

+44 -1
View File
@@ -70,11 +70,54 @@ function mayCopyFile (srcStat, src, dest, opts) {
}
function copyFile (srcStat, src, dest, opts) {
fs.copyFileSync(src, dest)
try {
fs.copyFileSync(src, dest)
} catch (err) {
if (err.code !== 'EPERM' || !srcStat.isFile()) throw err
copyFileWithReadWrite(src, dest)
}
if (opts.preserveTimestamps) handleTimestamps(srcStat.mode, src, dest)
return setDestMode(dest, srcStat.mode)
}
function copyFileWithReadWrite (src, dest) {
const buffer = Buffer.allocUnsafe(1024 * 1024)
const sourceFd = fs.openSync(src, 'r')
let destFd
let copyError
try {
destFd = fs.openSync(dest, 'w')
let bytesRead
while ((bytesRead = fs.readSync(sourceFd, buffer, 0, buffer.length, null)) > 0) {
let offset = 0
while (offset < bytesRead) {
const written = fs.writeSync(destFd, buffer, offset, bytesRead - offset)
if (written === 0) throw new Error('Failed to write file while copying')
offset += written
}
}
} catch (err) {
copyError = err
} finally {
try {
if (destFd !== undefined) fs.closeSync(destFd)
} catch (err) {
copyError ||= err
}
try {
fs.closeSync(sourceFd)
} catch (err) {
copyError ||= err
}
}
if (copyError) {
if (destFd !== undefined) {
try { fs.unlinkSync(dest) } catch (_) {}
}
throw copyError
}
}
function handleTimestamps (srcMode, src, dest) {
// Make sure the file is writable before setting the timestamp
// otherwise open fails with EPERM when invoked with 'r+'
+12 -1
View File
@@ -2,6 +2,7 @@
const fs = require('graceful-fs')
const path = require('path')
const { pipeline } = require('stream')
const mkdirs = require('../mkdirs').mkdirs
const pathExists = require('../path-exists').pathExists
const utimesMillis = require('../util/utimes').utimesMillis
@@ -98,10 +99,20 @@ function mayCopyFile (srcStat, src, dest, opts, cb) {
}
function copyFile (srcStat, src, dest, opts, cb) {
fs.copyFile(src, dest, err => {
const finish = err => {
if (err) return cb(err)
if (opts.preserveTimestamps) return handleTimestampsAndMode(srcStat.mode, src, dest, cb)
return setDestMode(dest, srcStat.mode, cb)
}
fs.copyFile(src, dest, err => {
if (err?.code !== 'EPERM' || !srcStat.isFile()) return finish(err)
// Stream the fallback so database copies do not require a full-file buffer.
pipeline(fs.createReadStream(src, { highWaterMark: 1024 * 1024 }), fs.createWriteStream(dest, { highWaterMark: 1024 * 1024 }), streamError => {
if (streamError) return fs.unlink(dest, () => cb(streamError))
finish()
})
})
}
+82
View File
@@ -0,0 +1,82 @@
const { expect } = require('chai')
const sinon = require('sinon')
const fs = require('graceful-fs')
const os = require('os')
const path = require('path')
const fsExtra = require('../../server/libs/fsExtra')
describe('fsExtra copy fallback', () => {
let dir
let source
let dest
let contents
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'abs-copy-'))
source = path.join(dir, 'source')
dest = path.join(dir, 'dest')
contents = Buffer.alloc(2 * 1024 * 1024 + 17, 0x61)
fs.writeFileSync(source, contents)
})
afterEach(() => {
sinon.restore()
fs.rmSync(dir, { recursive: true, force: true })
})
it('copies through streams when asynchronous copyFile is denied', async () => {
sinon.stub(fs, 'copyFile').callsFake((src, target, callback) => {
callback(Object.assign(new Error('copyfile denied'), { code: 'EPERM' }))
})
await fsExtra.copy(source, dest)
expect(fs.readFileSync(dest).equals(contents)).to.be.true
})
it('copies in chunks when synchronous copyFile is denied', () => {
sinon.stub(fs, 'copyFileSync').throws(Object.assign(new Error('copyfile denied'), { code: 'EPERM' }))
fsExtra.copySync(source, dest)
expect(fs.readFileSync(dest).equals(contents)).to.be.true
})
it('moves across devices when asynchronous copyFile is denied', async () => {
sinon.stub(fs, 'rename').callsFake((src, target, callback) => {
callback(Object.assign(new Error('cross-device link'), { code: 'EXDEV' }))
})
sinon.stub(fs, 'copyFile').callsFake((src, target, callback) => {
callback(Object.assign(new Error('copyfile denied'), { code: 'EPERM' }))
})
await fsExtra.move(source, dest)
expect(fs.existsSync(source)).to.be.false
expect(fs.readFileSync(dest).equals(contents)).to.be.true
})
it('moves across devices when synchronous copyFile is denied', () => {
sinon.stub(fs, 'renameSync').throws(Object.assign(new Error('cross-device link'), { code: 'EXDEV' }))
sinon.stub(fs, 'copyFileSync').throws(Object.assign(new Error('copyfile denied'), { code: 'EPERM' }))
fsExtra.moveSync(source, dest)
expect(fs.existsSync(source)).to.be.false
expect(fs.readFileSync(dest).equals(contents)).to.be.true
})
it('propagates unrelated copy errors', async () => {
sinon.stub(fs, 'copyFile').callsFake((src, target, callback) => {
callback(Object.assign(new Error('file missing'), { code: 'ENOENT' }))
})
try {
await fsExtra.copy(source, dest)
expect.fail('Expected copy to fail')
} catch (error) {
expect(error.code).to.equal('ENOENT')
}
expect(fs.existsSync(dest)).to.be.false
})
})