Skip to content
This repository has been archived by the owner on Aug 11, 2022. It is now read-only.

The great gently rm refactor #10270

Closed
wants to merge 4 commits into from
Closed
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
316 changes: 175 additions & 141 deletions lib/utils/gently-rm.js
@@ -1,21 +1,22 @@
// only remove the thing if it's a symlink into a specific folder.
// This is a very common use-case of npm's, but not so common elsewhere.
// only remove the thing if it's a symlink into a specific folder. This is
// a very common use-case of npm's, but not so common elsewhere.

module.exports = gentlyRm
exports = module.exports = gentlyRm

var npm = require('../npm.js')
var log = require('npmlog')
var resolve = require('path').resolve
var dirname = require('path').dirname
var normalize = require('path').normalize
var validate = require('aproba')
var log = require('npmlog')
var lstat = require('graceful-fs').lstat
var readlink = require('graceful-fs').readlink
var isInside = require('path-is-inside')
var vacuum = require('fs-vacuum')
var some = require('async-some')
var chain = require('slide').chain
var asyncMap = require('slide').asyncMap
var normalize = require('path').normalize
var readCmdShim = require('read-cmd-shim')
var iferr = require('iferr')
var npm = require('../npm.js')

function gentlyRm (target, gently, base, cb) {
if (!cb) {
Expand All @@ -37,8 +38,9 @@ function gentlyRm (target, gently, base, cb) {

// never rm the root, prefix, or bin dirs
//
// globals included because of `npm link` -- as far as the package requesting
// the link is concerned, the linked package is always installed globally
// globals included because of `npm link` -- as far as the package
// requesting the link is concerned, the linked package is always
// installed globally
var prefixes = [
npm.prefix,
npm.globalPrefix,
Expand All @@ -49,153 +51,192 @@ function gentlyRm (target, gently, base, cb) {
npm.globalBin
]

var resolved = normalize(resolve(npm.prefix, target))
if (prefixes.indexOf(resolved) !== -1) {
log.verbose('gentlyRm', resolved, "is part of npm and can't be removed")
return cb(new Error('May not delete: ' + resolved))
var targetPath = normalize(resolve(npm.prefix, target))
if (prefixes.indexOf(targetPath) !== -1) {
log.verbose('gentlyRm', targetPath, "is part of npm and can't be removed")
return cb(new Error('May not delete: ' + targetPath))
}
var options = { log: log.silly.bind(log, 'vacuum-fs') }
if (npm.config.get('force') || !gently) options.purge = true
if (base) options.base = normalize(resolve(npm.prefix, base))

follow(resolved, function (realpath) {
var options = { log: log.silly.bind(log, 'vacuum-fs') }
if (npm.config.get('force') || !gently) options.purge = true
if (base) options.base = normalize(resolve(npm.prefix, base))
if (!gently) {
log.verbose('gentlyRm', "don't care about contents; nuking", targetPath)
return vacuum(targetPath, options, cb)
}

if (!gently) {
log.verbose('gentlyRm', "don't care about contents; nuking", resolved)
return vacuum(resolved, options, cb)
var parent = options.base = options.base || normalize(npm.prefix)

// Do all the async work we'll need to do in order to tell if this is a
// safe operation
chain([
[isEverInside, parent, prefixes],
[readLinkOrShim, targetPath],
[isEverInside, targetPath, prefixes],
[isEverInside, targetPath, [parent]]
], function (er, results) {
if (er) {
if (er.code === 'ENOENT') return cb()
return cb(er)
}

var parent = options.base = normalize(base ? resolve(npm.prefix, base) : npm.prefix)

// is the parent directory managed by npm?
log.silly('gentlyRm', 'verifying', parent, 'is an npm working directory')
some(prefixes, isManaged(parent), function (er, matched) {
if (er) return cb(er)

if (!matched) {
log.error('gentlyRm', 'containing path', parent, "isn't under npm's control")
return clobberFail(resolved, parent, cb)
}
log.silly('gentlyRm', 'containing path', parent, "is under npm's control, in", matched)

// is the target directly contained within the (now known to be
// managed) parent?
if (isInside(resolved, parent)) {
log.silly('gentlyRm', 'deletion target', resolved, 'is under', parent)
log.verbose('gentlyRm', 'vacuuming from', resolved, 'up to', parent)
options.base = parent
return vacuum(resolved, options, cb)
}
log.silly('gentlyRm', realpath, 'is not under', parent)

// the target isn't directly within the parent, but is it itself managed?
log.silly('gentlyRm', 'verifying', realpath, 'is an npm working directory')
some(prefixes, isManaged(realpath), function (er, matched) {
if (er) return cb(er)

if (matched) {
log.silly('gentlyRm', resolved, "is under npm's control, in", matched)
if (isInside(realpath, parent)) {
log.silly('gentlyRm', realpath, 'is controlled by', parent)
options.base = matched
log.verbose('gentlyRm', 'removing', resolved, 'with base', options.base)
return vacuum(resolved, options, cb)
} else if (resolved !== realpath) {
log.warn('gentlyRm', 'not removing', resolved, "as it wasn't installed by", parent)
return cb()
}
}
log.verbose('gentlyRm', resolved, "is not under npm's control")

// the target isn't managed directly, but maybe it's a link...
log.silly('gentlyRm', 'checking to see if', resolved, 'is a link')
readLinkOrShim(resolved, function (er, link) {
if (er) {
// race conditions are common when unbuilding
if (er.code === 'ENOENT') return cb(null)
return cb(er)
}

if (!link) {
log.error('gentlyRm', resolved, 'is outside', parent, 'and not a link')
return clobberFail(resolved, parent, cb)
}

// ...and maybe the link source, when read...
log.silly('gentlyRm', resolved, 'is a link')
// ...is inside the managed parent
var source = resolve(dirname(resolved), link)
if (isInside(source, parent)) {
log.silly('gentlyRm', source, 'symlink target', resolved, 'is inside', parent)
log.verbose('gentlyRm', 'vacuuming', resolved)
return vacuum(resolved, options, cb)
}

log.error('gentlyRm', source, 'symlink target', resolved, 'is not controlled by npm', parent)
return clobberFail(target, parent, cb)
})
})
})
})
}

var resolvedPaths = {}
function isManaged (target) {
return function predicate (path, cb) {
if (!path) {
log.verbose('isManaged', 'no path passed for target', target)
return cb(null, false)
var parentInfo = {
path: parent,
managed: results[0]
}
var targetInfo = {
path: targetPath,
symlink: results[1],
managed: results[2],
inParent: results[3]
}

asyncMap([path, target], resolveSymlink, function (er, results) {
if (er) {
if (er.code === 'ENOENT') return cb(null, false)
isSafeToRm(parentInfo, targetInfo, iferr(cb, thenRemove))

return cb(er)
}
function thenRemove (toRemove, removeBase) {
if (!toRemove) return cb()
if (removeBase) options.base = removeBase
log.verbose('gentlyRm', options.purge ? 'Purging' : 'Vacuuming',
toRemove, 'up to', options.base)
return vacuum(toRemove, options, cb)
}
})
}

var path = results[0]
var target = results[1]
var inside = isInside(target, path)
if (!inside) log.silly('isManaged', target, 'is not inside', path)
exports._isSafeToRm = isSafeToRm
function isSafeToRm (parent, target, cb) {
log.silly('gentlyRm', 'parent.path =', parent.path)
log.silly('gentlyRm', 'parent.managed =',
parent.managed && parent.managed.target + ' is in ' + parent.managed.path)
log.silly('gentlyRm', 'target.path = ', target.path)
log.silly('gentlyRm', 'target.symlink =', target.symlink)
log.silly('gentlyRm', 'target.managed =',
target.managed && target.managed.target + ' is in ' + target.managed.path)
log.silly('gentlyRm', 'target.inParent = ', target.inParent)

// The parent directory or something it symlinks to must eventually be in
// a folder that npm maintains.
if (!parent.managed) {
log.verbose('gentlyRm', parent.path,
'is not contained in any diretory npm is known to control or ' +
'any place they link to')
return cb(clobberFail(target.path, 'containing path ' + parent.path +
" isn't under npm's control"))
}

return cb(null, inside && path)
})
// The target or something it symlinks to must eventually be in the parent
// or something the parent symlinks to
if (target.inParent) {
var actualTarget = target.inParent.target
var targetsParent = target.inParent.path
// if the target.path was what we found in some version of parent, remove
// using that parent as the base
if (target.path === actualTarget) {
return cb(null, target.path, targetsParent)
} else {
// If something the target.path links to was what was found, just
// remove target.path in the location it was found.
return cb(null, target.path, dirname(target.path))
}
}

function resolveSymlink (toResolve, cb) {
var resolved = resolve(npm.prefix, toResolve)
// If the target is in a managed directory and is in a symlink, but was
// not in our parent that usually means someone else installed a bin file
// with the same name as one of our bin files.
if (target.managed && target.symlink) {
log.warn('gentlyRm', 'not removing', target.path,
"as it wasn't installed by", parent.path)
return cb()
}

// if the path has already been memoized, return immediately
var cached = resolvedPaths[resolved]
if (cached) return cb(null, cached)
if (target.symlink) {
return cb(clobberFail(target.path, target.symlink +
' symlink target is not controlled by npm ' + parent.path))
} else {
return cb(clobberFail(target.path, 'is outside ' + parent.path +
' and not a link'))
}
}

// otherwise, check the path
readLinkOrShim(resolved, function (er, source) {
if (er) return cb(er)
function clobberFail (target, msg) {
validate('SS', arguments)
var er = new Error('Refusing to delete ' + target + ': ' + msg)
er.code = 'EEXIST'
er.path = target
return er
}

// if it's not a link, cache & return the path itself
if (!source) {
resolvedPaths[resolved] = resolved
return cb(null, resolved)
}
exports._isEverInside = isEverInside
// return the first of path, where target (or anything it symlinks to)
// isInside the path (or anything it symlinks to)
function isEverInside (target, paths, cb) {
validate('SAF', arguments)
function skipENOENT (er) {
if (er.code === 'ENOENT') return cb(null, false)
return cb(er)
}
asyncMap(paths, readAllLinks, iferr(skipENOENT, function (resolvedPaths) {
readAllLinks(target, iferr(skipENOENT, function (targets) {
cb(null, areAnyInsideAny(targets, resolvedPaths))
}))
}))
}

// otherwise, cache & return the link's source
resolved = resolve(resolved, source)
resolvedPaths[resolved] = resolved
cb(null, resolved)
exports._areAnyInsideAny = areAnyInsideAny
// Return the first path found that any target is inside
function areAnyInsideAny (targets, paths) {
validate('AA', arguments)
var toCheck = []
paths.forEach(function (path) {
targets.forEach(function (target) {
toCheck.push([target, path])
})
})
for (var ii = 0; ii < toCheck.length; ++ii) {
var target = toCheck[ii][0]
var path = toCheck[ii][1]
var inside = isInside(target, path)
if (!inside) log.silly('isEverInside', target, 'is not inside', path)
if (inside && path) return inside && path && {target: target, path: path}
}
return false
}

function clobberFail (target, root, cb) {
var er = new Error('Refusing to delete: ' + target + ' not in ' + root)
er.code = 'EEXIST'
er.path = target
return cb(er)
exports._readAllLinks = readAllLinks
// resolves chains of symlinks of unlimited depth, returning a list of paths
// it's seen in the process when it hits either a symlink cycle or a
// non-symlink
function readAllLinks (path, cb) {
validate('SF', arguments)
var seen = {}
_readAllLinks(path)

function _readAllLinks (path) {
if (seen[path]) return cb(null, Object.keys(seen))
seen[path] = true
resolveSymlink(path, iferr(cb, _readAllLinks))
}
}

exports._resolveSymlink = resolveSymlink
var resolvedPaths = {}
function resolveSymlink (symlink, cb) {
validate('SF', arguments)
var cached = resolvedPaths[symlink]
if (cached) return cb(null, cached)

readLinkOrShim(symlink, iferr(cb, function (symlinkTarget) {
if (symlinkTarget) {
resolvedPaths[symlink] = resolve(dirname(symlink), symlinkTarget)
} else {
resolvedPaths[symlink] = symlink
}
return cb(null, resolvedPaths[symlink])
}))
}

exports._readLinkOrShim = readLinkOrShim
function readLinkOrShim (path, cb) {
validate('SF', arguments)
lstat(path, iferr(cb, function (stat) {
if (stat.isSymbolicLink()) {
readlink(path, cb)
Expand All @@ -212,10 +253,3 @@ function readLinkOrShim (path, cb) {
}
}))
}

function follow (path, cb) {
readLinkOrShim(path, function (er, source) {
if (!source) return cb(path)
cb(normalize(resolve(dirname(path), source)))
})
}