Skip to content

Commit 3f2e2da

Browse files
committed
fix: normalize paths on Windows systems
This change uses / as the One True Path Separator, as the gods of POSIX intended in their divine wisdom. On windows, \ characters are converted to /, everywhere and in depth. However, on posix systems, \ is a valid filename character, and is not treated specially. So, instead of splitting on `/[/\\]/`, we can now just split on `'/'` to get a set of path parts. This does mean that archives with entries containing \ will extract differently on Windows systems than on correct systems. However, this is also the behavior of both bsdtar and gnutar, so it seems appropriate to follow suit. Additionally, dirCache pruning is now done case-insensitively. On case-sensitive systems, this potentially results in a few extra lstat calls. However, on case-insensitive systems, it prevents incorrect cache hits.
1 parent e29a665 commit 3f2e2da

9 files changed

+116
-55
lines changed

lib/mkdir.js

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const mkdirp = require('mkdirp')
88
const fs = require('fs')
99
const path = require('path')
1010
const chownr = require('chownr')
11+
const normPath = require('./normalize-windows-path.js')
1112

1213
class SymlinkError extends Error {
1314
constructor (symlink, path) {
@@ -33,7 +34,11 @@ class CwdError extends Error {
3334
}
3435
}
3536

36-
const mkdir = module.exports = (dir, opt, cb) => {
37+
const cGet = (cache, key) => cache.get(normPath(key))
38+
const cSet = (cache, key, val) => cache.set(normPath(key), val)
39+
40+
module.exports = (dir, opt, cb) => {
41+
dir = normPath(dir)
3742
// if there's any overlap between mask and mode,
3843
// then we'll need an explicit chmod
3944
const umask = opt.umask
@@ -49,13 +54,13 @@ const mkdir = module.exports = (dir, opt, cb) => {
4954
const preserve = opt.preserve
5055
const unlink = opt.unlink
5156
const cache = opt.cache
52-
const cwd = opt.cwd
57+
const cwd = normPath(opt.cwd)
5358

5459
const done = (er, created) => {
5560
if (er)
5661
cb(er)
5762
else {
58-
cache.set(dir, true)
63+
cSet(cache, dir, true)
5964
if (created && doChown)
6065
chownr(created, uid, gid, er => done(er))
6166
else if (needChmod)
@@ -65,7 +70,7 @@ const mkdir = module.exports = (dir, opt, cb) => {
6570
}
6671
}
6772

68-
if (cache && cache.get(dir) === true)
73+
if (cache && cGet(cache, dir) === true)
6974
return done()
7075

7176
if (dir === cwd)
@@ -79,7 +84,7 @@ const mkdir = module.exports = (dir, opt, cb) => {
7984
return mkdirp(dir, mode, done)
8085

8186
const sub = path.relative(cwd, dir)
82-
const parts = sub.split(/\/|\\/)
87+
const parts = sub.split('/')
8388
mkdir_(cwd, parts, mode, cache, unlink, cwd, null, done)
8489
}
8590

@@ -88,7 +93,7 @@ const mkdir_ = (base, parts, mode, cache, unlink, cwd, created, cb) => {
8893
return cb(null, created)
8994
const p = parts.shift()
9095
const part = base + '/' + p
91-
if (cache.get(part))
96+
if (cGet(cache, part))
9297
return mkdir_(part, parts, mode, cache, unlink, cwd, created, cb)
9398
fs.mkdir(part, mode, onmkdir(part, parts, mode, cache, unlink, cwd, created, cb))
9499
}
@@ -121,7 +126,8 @@ const onmkdir = (part, parts, mode, cache, unlink, cwd, created, cb) => er => {
121126
}
122127
}
123128

124-
const mkdirSync = module.exports.sync = (dir, opt) => {
129+
module.exports.sync = (dir, opt) => {
130+
dir = normPath(dir)
125131
// if there's any overlap between mask and mode,
126132
// then we'll need an explicit chmod
127133
const umask = opt.umask
@@ -137,17 +143,17 @@ const mkdirSync = module.exports.sync = (dir, opt) => {
137143
const preserve = opt.preserve
138144
const unlink = opt.unlink
139145
const cache = opt.cache
140-
const cwd = opt.cwd
146+
const cwd = normPath(opt.cwd)
141147

142148
const done = (created) => {
143-
cache.set(dir, true)
149+
cSet(cache, dir, true)
144150
if (created && doChown)
145151
chownr.sync(created, uid, gid)
146152
if (needChmod)
147153
fs.chmodSync(dir, mode)
148154
}
149155

150-
if (cache && cache.get(dir) === true)
156+
if (cache && cGet(cache, dir) === true)
151157
return done()
152158

153159
if (dir === cwd) {
@@ -169,33 +175,32 @@ const mkdirSync = module.exports.sync = (dir, opt) => {
169175
return done(mkdirp.sync(dir, mode))
170176

171177
const sub = path.relative(cwd, dir)
172-
const parts = sub.split(/\/|\\/)
178+
const parts = sub.split('/')
173179
let created = null
174180
for (let p = parts.shift(), part = cwd;
175-
p && (part += '/' + p);
176-
p = parts.shift()) {
177-
178-
if (cache.get(part))
181+
p && (part += '/' + p);
182+
p = parts.shift()) {
183+
if (cGet(cache, part))
179184
continue
180185

181186
try {
182187
fs.mkdirSync(part, mode)
183188
created = created || part
184-
cache.set(part, true)
189+
cSet(cache, part, true)
185190
} catch (er) {
186191
if (er.path && path.dirname(er.path) === cwd &&
187192
(er.code === 'ENOTDIR' || er.code === 'ENOENT'))
188193
return new CwdError(cwd, er.code)
189194

190195
const st = fs.lstatSync(part)
191196
if (st.isDirectory()) {
192-
cache.set(part, true)
197+
cSet(cache, part, true)
193198
continue
194199
} else if (unlink) {
195200
fs.unlinkSync(part)
196201
fs.mkdirSync(part, mode)
197202
created = created || part
198-
cache.set(part, true)
203+
cSet(cache, part, true)
199204
continue
200205
} else if (st.isSymbolicLink())
201206
return new SymlinkError(part, part + '/' + parts.join('/'))

lib/normalize-windows-path.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
// on windows, either \ or / are valid directory separators.
2+
// on unix, \ is a valid character in filenames.
3+
// so, on windows, and only on windows, we replace all \ chars with /,
4+
// so that we can use / as our one and only directory separator char.
5+
6+
const platform = process.env.TESTING_TAR_FAKE_PLATFORM || process.platform
7+
module.exports = platform !== 'win32' ? p => p
8+
: p => p.replace(/\\/g, '/')

lib/pack.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ const ONDRAIN = Symbol('ondrain')
5656
const fs = require('fs')
5757
const path = require('path')
5858
const warner = require('./warn-mixin.js')
59+
const normPath = require('./normalize-windows-path.js')
5960

6061
const Pack = warner(class Pack extends MiniPass {
6162
constructor (opt) {
@@ -67,7 +68,7 @@ const Pack = warner(class Pack extends MiniPass {
6768
this.preservePaths = !!opt.preservePaths
6869
this.strict = !!opt.strict
6970
this.noPax = !!opt.noPax
70-
this.prefix = (opt.prefix || '').replace(/(\\|\/)+$/, '')
71+
this.prefix = normPath(opt.prefix || '')
7172
this.linkCache = opt.linkCache || new Map()
7273
this.statCache = opt.statCache || new Map()
7374
this.readdirCache = opt.readdirCache || new Map()
@@ -132,7 +133,7 @@ const Pack = warner(class Pack extends MiniPass {
132133
}
133134

134135
[ADDTARENTRY] (p) {
135-
const absolute = path.resolve(this.cwd, p.path)
136+
const absolute = normPath(path.resolve(this.cwd, p.path))
136137
// in this case, we don't have to wait for the stat
137138
if (!this.filter(p.path, p))
138139
p.resume()
@@ -148,7 +149,7 @@ const Pack = warner(class Pack extends MiniPass {
148149
}
149150

150151
[ADDFSENTRY] (p) {
151-
const absolute = path.resolve(this.cwd, p)
152+
const absolute = normPath(path.resolve(this.cwd, p))
152153
this[QUEUE].push(new PackJob(p, absolute))
153154
this[PROCESS]()
154155
}

lib/path-reservations.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
// while still allowing maximal safe parallelization.
88

99
const assert = require('assert')
10+
const normPath = require('./normalize-windows-path.js')
1011

1112
module.exports = () => {
1213
// path => [function or Set]
@@ -20,8 +21,9 @@ module.exports = () => {
2021
// return a set of parent dirs for a given path
2122
const { join } = require('path')
2223
const getDirs = path =>
23-
join(path).split(/[\\\/]/).slice(0, -1).reduce((set, path) =>
24-
set.length ? set.concat(join(set[set.length-1], path)) : [path], [])
24+
normPath(join(path)).split('/').slice(0, -1).reduce((set, path) =>
25+
set.length ? set.concat(normPath(join(set[set.length-1], path)))
26+
: [path], [])
2527

2628
// functions currently running
2729
const running = new Set()

lib/unpack.js

Lines changed: 28 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,17 @@ const uint32 = (a, b, c) =>
9494
: b === b >>> 0 ? b
9595
: c
9696

97+
const pruneCache = (cache, abs) => {
98+
// clear the cache if it's a case-insensitive match, since we can't
99+
// know if the current file system is case-sensitive or not.
100+
abs = normPath(abs).toLowerCase()
101+
for (const path of cache.keys()) {
102+
const plower = path.toLowerCase()
103+
if (plower === abs || plower.toLowerCase().indexOf(abs + '/') === 0)
104+
cache.delete(path)
105+
}
106+
}
107+
97108
class Unpack extends Parser {
98109
constructor (opt) {
99110
if (!opt)
@@ -170,7 +181,7 @@ class Unpack extends Parser {
170181
// links, and removes symlink directories rather than erroring
171182
this.unlink = !!opt.unlink
172183

173-
this.cwd = path.resolve(opt.cwd || process.cwd())
184+
this.cwd = normPath(path.resolve(opt.cwd || process.cwd()))
174185
this.strip = +opt.strip || 0
175186
this.processUmask = process.umask()
176187
this.umask = typeof opt.umask === 'number' ? opt.umask : this.processUmask
@@ -191,24 +202,24 @@ class Unpack extends Parser {
191202

192203
[CHECKPATH] (entry) {
193204
if (this.strip) {
194-
const parts = entry.path.split(/\/|\\/)
205+
const parts = normPath(entry.path).split('/')
195206
if (parts.length < this.strip)
196207
return false
197208
entry.path = parts.slice(this.strip).join('/')
198209
if (entry.path === '' && entry.type !== 'Directory' && entry.type !== 'GNUDumpDir')
199210
return false
200211

201212
if (entry.type === 'Link') {
202-
const linkparts = entry.linkpath.split(/\/|\\/)
213+
const linkparts = normPath(entry.linkpath).split('/')
203214
if (linkparts.length >= this.strip)
204215
entry.linkpath = linkparts.slice(this.strip).join('/')
205216
}
206217
}
207218

208219
if (!this.preservePaths) {
209-
const p = entry.path
210-
if (p.match(/(^|\/|\\)\.\.(\\|\/|$)/)) {
211-
this.warn('path contains \'..\'', p)
220+
const p = normPath(entry.path)
221+
if (p.split('/').includes('..')) {
222+
this.warn(`path contains '..'`, p)
212223
return false
213224
}
214225

@@ -229,9 +240,9 @@ class Unpack extends Parser {
229240
}
230241

231242
if (path.isAbsolute(entry.path))
232-
entry.absolute = entry.path
243+
entry.absolute = normPath(entry.path)
233244
else
234-
entry.absolute = path.resolve(this.cwd, entry.path)
245+
entry.absolute = normPath(path.resolve(this.cwd, entry.path))
235246

236247
return true
237248
}
@@ -276,7 +287,7 @@ class Unpack extends Parser {
276287
}
277288

278289
[MKDIR] (dir, mode, cb) {
279-
mkdir(dir, {
290+
mkdir(normPath(dir), {
280291
uid: this.uid,
281292
gid: this.gid,
282293
processUid: this.processUid,
@@ -408,7 +419,8 @@ class Unpack extends Parser {
408419
}
409420

410421
[HARDLINK] (entry, done) {
411-
this[LINK](entry, path.resolve(this.cwd, entry.linkpath), 'link', done)
422+
const linkpath = normPath(path.resolve(this.cwd, entry.linkpath))
423+
this[LINK](entry, linkpath, 'link', done)
412424
}
413425

414426
[PEND] () {
@@ -444,14 +456,8 @@ class Unpack extends Parser {
444456
// then that means we are about to delete the directory we created
445457
// previously, and it is no longer going to be a directory, and neither
446458
// is any of its children.
447-
if (entry.type !== 'Directory') {
448-
for (const path of this.dirCache.keys()) {
449-
if (path === entry.absolute ||
450-
path.indexOf(entry.absolute + '/') === 0 ||
451-
path.indexOf(entry.absolute + '\\') === 0)
452-
this.dirCache.delete(path)
453-
}
454-
}
459+
if (entry.type !== 'Directory')
460+
pruneCache(this.dirCache, entry.absolute)
455461

456462
const paths = [entry.path]
457463
if (entry.linkpath)
@@ -508,7 +514,7 @@ class Unpack extends Parser {
508514
}
509515

510516
[LINK] (entry, linkpath, link, done) {
511-
// XXX: get the type ('file' or 'dir') for windows
517+
// XXX: get the type ('symlink' or 'junction') for windows
512518
fs[link](linkpath, entry.absolute, er => {
513519
if (er)
514520
return this[ONERROR](er, entry)
@@ -525,14 +531,8 @@ class UnpackSync extends Unpack {
525531
}
526532

527533
[CHECKFS] (entry) {
528-
if (entry.type !== 'Directory') {
529-
for (const path of this.dirCache.keys()) {
530-
if (path === entry.absolute ||
531-
path.indexOf(entry.absolute + '/') === 0 ||
532-
path.indexOf(entry.absolute + '\\') === 0)
533-
this.dirCache.delete(path)
534-
}
535-
}
534+
if (entry.type !== 'Directory')
535+
pruneCache(this.dirCache, entry.absolute)
536536

537537
const er = this[MKDIR](path.dirname(entry.absolute), this.dmode, neverCalled)
538538
if (er)
@@ -650,7 +650,7 @@ class UnpackSync extends Unpack {
650650

651651
[MKDIR] (dir, mode) {
652652
try {
653-
return mkdir.sync(dir, {
653+
return mkdir.sync(normPath(dir), {
654654
uid: this.uid,
655655
gid: this.gid,
656656
processUid: this.processUid,

lib/write-entry.js

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@ const Header = require('./header.js')
66
const ReadEntry = require('./read-entry.js')
77
const fs = require('fs')
88
const path = require('path')
9+
const normPath = require('./normalize-windows-path.js')
10+
const stripSlash = require('./strip-trailing-slashes.js')
911

1012
const prefixPath = (path, prefix) => {
1113
if (!prefix)
1214
return path
13-
path = path.replace(/^\.([/\\]|$)/, '')
14-
return prefix + '/' + path
15+
path = normPath(path).replace(/^\.(\/|$)/, '')
16+
return stripSlash(prefix) + '/' + path
1517
}
1618

1719
const maxReadSize = 16 * 1024 * 1024
@@ -45,7 +47,7 @@ const WriteEntry = warner(class WriteEntry extends MiniPass {
4547
super(opt)
4648
if (typeof p !== 'string')
4749
throw new TypeError('path is required')
48-
this.path = p
50+
this.path = normPath(p)
4951
// suppress atime, ctime, uid, gid, uname, gname
5052
this.portable = !!opt.portable
5153
// until node has builtin pwnam functions, this'll have to do
@@ -88,7 +90,7 @@ const WriteEntry = warner(class WriteEntry extends MiniPass {
8890
p = p.replace(/\\/g, '/')
8991
}
9092

91-
this.absolute = opt.absolute || path.resolve(this.cwd, p)
93+
this.absolute = normPath(opt.absolute || path.resolve(this.cwd, p))
9294

9395
if (this.path === '')
9496
this.path = './'

0 commit comments

Comments
 (0)