Description: normalize paths on Windows systems Author: isaacs <i@izs.me> Origin: upstream, https://github.com/npm/node-tar/commit/53602669 Bug: https://github.com/npm/node-tar/security/advisories/GHSA-9r2w-394v-53qc Forwarded: not-needed Reviewed-By: Yadd <yadd@debian.org> Last-Update: 2021-11-11 --- a/lib/mkdir.js +++ b/lib/mkdir.js @@ -8,6 +8,7 @@ const fs = require('fs') const path = require('path') const chownr = require('chownr') +const normPath = require('./normalize-windows-path.js') class SymlinkError extends Error { constructor (symlink, path) { @@ -33,7 +34,11 @@ } } +const cGet = (cache, key) => cache.get(normPath(key)) +const cSet = (cache, key, val) => cache.set(normPath(key), val) + const mkdir = module.exports = (dir, opt, cb) => { + dir = normPath(dir) // if there's any overlap between mask and mode, // then we'll need an explicit chmod const umask = opt.umask @@ -49,13 +54,13 @@ const preserve = opt.preserve const unlink = opt.unlink const cache = opt.cache - const cwd = opt.cwd + const cwd = normPath(opt.cwd) const done = (er, created) => { if (er) cb(er) else { - cache.set(dir, true) + cSet(cache, dir, true) if (created && doChown) chownr(created, uid, gid, er => done(er)) else if (needChmod) @@ -65,7 +70,7 @@ } } - if (cache && cache.get(dir) === true) + if (cache && cGet(cache, dir) === true) return done() if (dir === cwd) @@ -79,7 +84,7 @@ return mkdirp(dir, {mode}).then(made => done(null, made), done) const sub = path.relative(cwd, dir) - const parts = sub.split(/\/|\\/) + const parts = sub.split('/') mkdir_(cwd, parts, mode, cache, unlink, cwd, null, done) } @@ -88,7 +93,7 @@ return cb(null, created) const p = parts.shift() const part = base + '/' + p - if (cache.get(part)) + if (cGet(cache, part)) return mkdir_(part, parts, mode, cache, unlink, cwd, created, cb) fs.mkdir(part, mode, onmkdir(part, parts, mode, cache, unlink, cwd, created, cb)) } @@ -122,6 +127,7 @@ } const mkdirSync = module.exports.sync = (dir, opt) => { + dir = normPath(dir) // if there's any overlap between mask and mode, // then we'll need an explicit chmod const umask = opt.umask @@ -137,17 +143,17 @@ const preserve = opt.preserve const unlink = opt.unlink const cache = opt.cache - const cwd = opt.cwd + const cwd = normPath(opt.cwd) const done = (created) => { - cache.set(dir, true) + cSet(cache, dir, true) if (created && doChown) chownr.sync(created, uid, gid) if (needChmod) fs.chmodSync(dir, mode) } - if (cache && cache.get(dir) === true) + if (cache && cGet(cache, dir) === true) return done() if (dir === cwd) { @@ -169,19 +175,19 @@ return done(mkdirp.sync(dir, mode)) const sub = path.relative(cwd, dir) - const parts = sub.split(/\/|\\/) + const parts = sub.split('/') let created = null for (let p = parts.shift(), part = cwd; p && (part += '/' + p); p = parts.shift()) { - if (cache.get(part)) + if (cGet(cache, part)) continue try { fs.mkdirSync(part, mode) created = created || part - cache.set(part, true) + cSet(cache, part, true) } catch (er) { if (er.path && path.dirname(er.path) === cwd && (er.code === 'ENOTDIR' || er.code === 'ENOENT')) @@ -189,13 +195,13 @@ const st = fs.lstatSync(part) if (st.isDirectory()) { - cache.set(part, true) + cSet(cache, part, true) continue } else if (unlink) { fs.unlinkSync(part) fs.mkdirSync(part, mode) created = created || part - cache.set(part, true) + cSet(cache, part, true) continue } else if (st.isSymbolicLink()) return new SymlinkError(part, part + '/' + parts.join('/')) --- /dev/null +++ b/lib/normalize-windows-path.js @@ -0,0 +1,8 @@ +// on windows, either \ or / are valid directory separators. +// on unix, \ is a valid character in filenames. +// so, on windows, and only on windows, we replace all \ chars with /, +// so that we can use / as our one and only directory separator char. + +const platform = process.env.TESTING_TAR_FAKE_PLATFORM || process.platform +module.exports = platform !== 'win32' ? p => p + : p => p.replace(/\\/g, '/') --- a/lib/pack.js +++ b/lib/pack.js @@ -54,6 +54,7 @@ const fs = require('fs') const path = require('path') const warner = require('./warn-mixin.js') +const normPath = require('./normalize-windows-path.js') const Pack = warner(class Pack extends MiniPass { constructor (opt) { @@ -66,7 +67,7 @@ this.preservePaths = !!opt.preservePaths this.strict = !!opt.strict this.noPax = !!opt.noPax - this.prefix = (opt.prefix || '').replace(/(\\|\/)+$/, '') + this.prefix = normPath(opt.prefix || '') this.linkCache = opt.linkCache || new Map() this.statCache = opt.statCache || new Map() this.readdirCache = opt.readdirCache || new Map() @@ -133,7 +134,7 @@ } [ADDTARENTRY] (p) { - const absolute = path.resolve(this.cwd, p.path) + const absolute = normPath(path.resolve(this.cwd, p.path)) if (this.prefix) p.path = this.prefix + '/' + p.path.replace(/^\.(\/+|$)/, '') @@ -152,7 +153,7 @@ } [ADDFSENTRY] (p) { - const absolute = path.resolve(this.cwd, p) + const absolute = normPath(path.resolve(this.cwd, p)) if (this.prefix) p = this.prefix + '/' + p.replace(/^\.(\/+|$)/, '') --- a/lib/path-reservations.js +++ b/lib/path-reservations.js @@ -7,6 +7,7 @@ // while still allowing maximal safe parallelization. const assert = require('assert') +const normPath = require('./normalize-windows-path.js') module.exports = () => { // path => [function or Set] @@ -20,8 +21,9 @@ // return a set of parent dirs for a given path const { join } = require('path') const getDirs = path => - join(path).split(/[\\\/]/).slice(0, -1).reduce((set, path) => - set.length ? set.concat(join(set[set.length-1], path)) : [path], []) + normPath(join(path)).split('/').slice(0, -1).reduce((set, path) => + set.length ? set.concat(normPath(join(set[set.length - 1], path))) + : [path], []) // functions currently running const running = new Set() --- /dev/null +++ b/lib/strip-trailing-slashes.js @@ -0,0 +1,13 @@ +// warning: extremely hot code path. +// This has been meticulously optimized for use +// within npm install on large package trees. +// Do not edit without careful benchmarking. +module.exports = str => { + let i = str.length - 1 + let slashesStart = -1 + while (i > -1 && str.charAt(i) === '/') { + slashesStart = i + i-- + } + return slashesStart === -1 ? str : str.slice(0, slashesStart) +} --- a/lib/unpack.js +++ b/lib/unpack.js @@ -17,6 +17,7 @@ const wc = require('./winchars.js') const pathReservations = require('./path-reservations.js') const stripAbsolutePath = require('./strip-absolute-path.js') +const normPath = require('./normalize-windows-path.js') const ONENTRY = Symbol('onEntry') const CHECKFS = Symbol('checkFs') @@ -94,6 +95,17 @@ : b === b >>> 0 ? b : c +const pruneCache = (cache, abs) => { + // clear the cache if it's a case-insensitive match, since we can't + // know if the current file system is case-sensitive or not. + abs = normPath(abs).toLowerCase() + for (const path of cache.keys()) { + const plower = path.toLowerCase() + if (plower === abs || plower.toLowerCase().indexOf(abs + '/') === 0) + cache.delete(path) + } +} + class Unpack extends Parser { constructor (opt) { if (!opt) @@ -170,7 +182,7 @@ // links, and removes symlink directories rather than erroring this.unlink = !!opt.unlink - this.cwd = path.resolve(opt.cwd || process.cwd()) + this.cwd = normPath(path.resolve(opt.cwd || process.cwd())) this.strip = +opt.strip || 0 this.processUmask = process.umask() this.umask = typeof opt.umask === 'number' ? opt.umask : this.processUmask @@ -200,21 +212,21 @@ [CHECKPATH] (entry) { if (this.strip) { - const parts = entry.path.split(/\/|\\/) + const parts = normPath(entry.path).split('/') if (parts.length < this.strip) return false entry.path = parts.slice(this.strip).join('/') if (entry.type === 'Link') { - const linkparts = entry.linkpath.split(/\/|\\/) + const linkparts = normPath(entry.linkpath).split('/') if (linkparts.length >= this.strip) entry.linkpath = linkparts.slice(this.strip).join('/') } } if (!this.preservePaths) { - const p = entry.path - if (p.match(/(^|\/|\\)\.\.(\\|\/|$)/)) { + const p = normPath(entry.path) + if (p.split('/').includes('..')) { this.warn('TAR_ENTRY_ERROR', `path contains '..'`, { entry, path: p, @@ -242,9 +254,9 @@ } if (path.isAbsolute(entry.path)) - entry.absolute = entry.path + entry.absolute = normPath(entry.path) else - entry.absolute = path.resolve(this.cwd, entry.path) + entry.absolute = normPath(path.resolve(this.cwd, entry.path)) return true } @@ -289,7 +301,7 @@ } [MKDIR] (dir, mode, cb) { - mkdir(dir, { + mkdir(normPath(dir), { uid: this.uid, gid: this.gid, processUid: this.processUid, @@ -424,7 +436,8 @@ } [HARDLINK] (entry, done) { - this[LINK](entry, path.resolve(this.cwd, entry.linkpath), 'link', done) + const linkpath = normPath(path.resolve(this.cwd, entry.linkpath)) + this[LINK](entry, linkpath, 'link', done) } [PEND] () { @@ -465,14 +478,8 @@ // then that means we are about to delete the directory we created // previously, and it is no longer going to be a directory, and neither // is any of its children. - if (entry.type !== 'Directory') { - for (const path of this.dirCache.keys()) { - if (path === entry.absolute || - path.indexOf(entry.absolute + '/') === 0 || - path.indexOf(entry.absolute + '\\') === 0) - this.dirCache.delete(path) - } - } + if (entry.type !== 'Directory') + pruneCache(this.dirCache, entry.absolute) this[MKDIR](path.dirname(entry.absolute), this.dmode, er => { if (er) { @@ -524,7 +531,7 @@ } [LINK] (entry, linkpath, link, done) { - // XXX: get the type ('file' or 'dir') for windows + // XXX: get the type ('symlink' or 'junction') for windows fs[link](linkpath, entry.absolute, er => { if (er) return this[ONERROR](er, entry) @@ -541,14 +548,8 @@ } [CHECKFS] (entry) { - if (entry.type !== 'Directory') { - for (const path of this.dirCache.keys()) { - if (path === entry.absolute || - path.indexOf(entry.absolute + '/') === 0 || - path.indexOf(entry.absolute + '\\') === 0) - this.dirCache.delete(path) - } - } + if (entry.type !== 'Directory') + pruneCache(this.dirCache, entry.absolute) const er = this[MKDIR](path.dirname(entry.absolute), this.dmode, neverCalled) if (er) @@ -671,7 +672,7 @@ [MKDIR] (dir, mode) { try { - return mkdir.sync(dir, { + return mkdir.sync(normPath(dir), { uid: this.uid, gid: this.gid, processUid: this.processUid, --- a/lib/write-entry.js +++ b/lib/write-entry.js @@ -5,6 +5,8 @@ const ReadEntry = require('./read-entry.js') const fs = require('fs') const path = require('path') +const normPath = require('./normalize-windows-path.js') +const stripSlash = require('./strip-trailing-slashes.js') const types = require('./types.js') const maxReadSize = 16 * 1024 * 1024 @@ -35,7 +37,7 @@ super(opt) if (typeof p !== 'string') throw new TypeError('path is required') - this.path = p + this.path = normPath(p) // suppress atime, ctime, uid, gid, uname, gname this.portable = !!opt.portable // until node has builtin pwnam functions, this'll have to do @@ -69,7 +71,7 @@ p = p.replace(/\\/g, '/') } - this.absolute = opt.absolute || path.resolve(this.cwd, p) + this.absolute = normPath(opt.absolute || path.resolve(this.cwd, p)) if (this.path === '') this.path = './' @@ -175,14 +177,14 @@ } [ONREADLINK] (linkpath) { - this.linkpath = linkpath.replace(/\\/g, '/') + this.linkpath = normPath(linkpath) this[HEADER]() this.end() } [HARDLINK] (linkpath) { this.type = 'Link' - this.linkpath = path.relative(this.cwd, linkpath).replace(/\\/g, '/') + this.linkpath = normPath(path.relative(this.cwd, linkpath)) this.stat.size = 0 this[HEADER]() this.end()