Skip to content

Commit 5360266

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 9bc1729 commit 5360266

7 files changed

+95
-49
lines changed

lib/mkdir.js

Lines changed: 19 additions & 13 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

37+
const cGet = (cache, key) => cache.get(normPath(key))
38+
const cSet = (cache, key, val) => cache.set(normPath(key), val)
39+
3640
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 @@ 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 @@ 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) {
@@ -80,7 +85,7 @@ module.exports = (dir, opt, cb) => {
8085
return mkdirp(dir, {mode}).then(made => done(null, made), done)
8186

8287
const sub = path.relative(cwd, dir)
83-
const parts = sub.split(/\/|\\/)
88+
const parts = sub.split('/')
8489
mkdir_(cwd, parts, mode, cache, unlink, cwd, null, done)
8590
}
8691

@@ -89,7 +94,7 @@ const mkdir_ = (base, parts, mode, cache, unlink, cwd, created, cb) => {
8994
return cb(null, created)
9095
const p = parts.shift()
9196
const part = base + '/' + p
92-
if (cache.get(part))
97+
if (cGet(cache, part))
9398
return mkdir_(part, parts, mode, cache, unlink, cwd, created, cb)
9499
fs.mkdir(part, mode, onmkdir(part, parts, mode, cache, unlink, cwd, created, cb))
95100
}
@@ -123,6 +128,7 @@ const onmkdir = (part, parts, mode, cache, unlink, cwd, created, cb) => er => {
123128
}
124129

125130
module.exports.sync = (dir, opt) => {
131+
dir = normPath(dir)
126132
// if there's any overlap between mask and mode,
127133
// then we'll need an explicit chmod
128134
const umask = opt.umask
@@ -138,17 +144,17 @@ module.exports.sync = (dir, opt) => {
138144
const preserve = opt.preserve
139145
const unlink = opt.unlink
140146
const cache = opt.cache
141-
const cwd = opt.cwd
147+
const cwd = normPath(opt.cwd)
142148

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

151-
if (cache && cache.get(dir) === true)
157+
if (cache && cGet(cache, dir) === true)
152158
return done()
153159

154160
if (dir === cwd) {
@@ -170,32 +176,32 @@ module.exports.sync = (dir, opt) => {
170176
return done(mkdirp.sync(dir, mode))
171177

172178
const sub = path.relative(cwd, dir)
173-
const parts = sub.split(/\/|\\/)
179+
const parts = sub.split('/')
174180
let created = null
175181
for (let p = parts.shift(), part = cwd;
176182
p && (part += '/' + p);
177183
p = parts.shift()) {
178-
if (cache.get(part))
184+
if (cGet(cache, part))
179185
continue
180186

181187
try {
182188
fs.mkdirSync(part, mode)
183189
created = created || part
184-
cache.set(part, true)
190+
cSet(cache, part, true)
185191
} catch (er) {
186192
if (er.path && path.dirname(er.path) === cwd &&
187193
(er.code === 'ENOTDIR' || er.code === 'ENOENT'))
188194
return new CwdError(cwd, er.code)
189195

190196
const st = fs.lstatSync(part)
191197
if (st.isDirectory()) {
192-
cache.set(part, true)
198+
cSet(cache, part, true)
193199
continue
194200
} else if (unlink) {
195201
fs.unlinkSync(part)
196202
fs.mkdirSync(part, mode)
197203
created = created || part
198-
cache.set(part, true)
204+
cSet(cache, part, true)
199205
continue
200206
} else if (st.isSymbolicLink())
201207
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
@@ -54,6 +54,7 @@ const ONDRAIN = Symbol('ondrain')
5454
const fs = require('fs')
5555
const path = require('path')
5656
const warner = require('./warn-mixin.js')
57+
const normPath = require('./normalize-windows-path.js')
5758

5859
const Pack = warner(class Pack extends MiniPass {
5960
constructor (opt) {
@@ -66,7 +67,7 @@ const Pack = warner(class Pack extends MiniPass {
6667
this.preservePaths = !!opt.preservePaths
6768
this.strict = !!opt.strict
6869
this.noPax = !!opt.noPax
69-
this.prefix = (opt.prefix || '').replace(/(\\|\/)+$/, '')
70+
this.prefix = normPath(opt.prefix || '')
7071
this.linkCache = opt.linkCache || new Map()
7172
this.statCache = opt.statCache || new Map()
7273
this.readdirCache = opt.readdirCache || new Map()
@@ -133,7 +134,7 @@ const Pack = warner(class Pack extends MiniPass {
133134
}
134135

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

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

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 & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const mkdir = require('./mkdir.js')
1515
const wc = require('./winchars.js')
1616
const pathReservations = require('./path-reservations.js')
1717
const stripAbsolutePath = require('./strip-absolute-path.js')
18+
const normPath = require('./normalize-windows-path.js')
1819

1920
const ONENTRY = Symbol('onEntry')
2021
const CHECKFS = Symbol('checkFs')
@@ -91,6 +92,17 @@ const uint32 = (a, b, c) =>
9192
: b === b >>> 0 ? b
9293
: c
9394

95+
const pruneCache = (cache, abs) => {
96+
// clear the cache if it's a case-insensitive match, since we can't
97+
// know if the current file system is case-sensitive or not.
98+
abs = normPath(abs).toLowerCase()
99+
for (const path of cache.keys()) {
100+
const plower = path.toLowerCase()
101+
if (plower === abs || plower.toLowerCase().indexOf(abs + '/') === 0)
102+
cache.delete(path)
103+
}
104+
}
105+
94106
class Unpack extends Parser {
95107
constructor (opt) {
96108
if (!opt)
@@ -168,7 +180,7 @@ class Unpack extends Parser {
168180
// links, and removes symlink directories rather than erroring
169181
this.unlink = !!opt.unlink
170182

171-
this.cwd = path.resolve(opt.cwd || process.cwd())
183+
this.cwd = normPath(path.resolve(opt.cwd || process.cwd()))
172184
this.strip = +opt.strip || 0
173185
// if we're not chmodding, then we don't need the process umask
174186
this.processUmask = opt.noChmod ? 0 : process.umask()
@@ -201,23 +213,23 @@ class Unpack extends Parser {
201213

202214
[CHECKPATH] (entry) {
203215
if (this.strip) {
204-
const parts = entry.path.split(/\/|\\/)
216+
const parts = normPath(entry.path).split('/')
205217
if (parts.length < this.strip)
206218
return false
207219
entry.path = parts.slice(this.strip).join('/')
208220
if (entry.path === '' && entry.type !== 'Directory' && entry.type !== 'GNUDumpDir')
209221
return false
210222

211223
if (entry.type === 'Link') {
212-
const linkparts = entry.linkpath.split(/\/|\\/)
224+
const linkparts = normPath(entry.linkpath).split('/')
213225
if (linkparts.length >= this.strip)
214226
entry.linkpath = linkparts.slice(this.strip).join('/')
215227
}
216228
}
217229

218230
if (!this.preservePaths) {
219-
const p = entry.path
220-
if (p.match(/(^|\/|\\)\.\.(\\|\/|$)/)) {
231+
const p = normPath(entry.path)
232+
if (p.split('/').includes('..')) {
221233
this.warn('TAR_ENTRY_ERROR', `path contains '..'`, {
222234
entry,
223235
path: p,
@@ -245,9 +257,9 @@ class Unpack extends Parser {
245257
}
246258

247259
if (path.isAbsolute(entry.path))
248-
entry.absolute = entry.path
260+
entry.absolute = normPath(entry.path)
249261
else
250-
entry.absolute = path.resolve(this.cwd, entry.path)
262+
entry.absolute = normPath(path.resolve(this.cwd, entry.path))
251263

252264
return true
253265
}
@@ -293,7 +305,7 @@ class Unpack extends Parser {
293305
}
294306

295307
[MKDIR] (dir, mode, cb) {
296-
mkdir(dir, {
308+
mkdir(normPath(dir), {
297309
uid: this.uid,
298310
gid: this.gid,
299311
processUid: this.processUid,
@@ -451,7 +463,8 @@ class Unpack extends Parser {
451463
}
452464

453465
[HARDLINK] (entry, done) {
454-
this[LINK](entry, path.resolve(this.cwd, entry.linkpath), 'link', done)
466+
const linkpath = normPath(path.resolve(this.cwd, entry.linkpath))
467+
this[LINK](entry, linkpath, 'link', done)
455468
}
456469

457470
[PEND] () {
@@ -493,14 +506,8 @@ class Unpack extends Parser {
493506
// then that means we are about to delete the directory we created
494507
// previously, and it is no longer going to be a directory, and neither
495508
// is any of its children.
496-
if (entry.type !== 'Directory') {
497-
for (const path of this.dirCache.keys()) {
498-
if (path === entry.absolute ||
499-
path.indexOf(entry.absolute + '/') === 0 ||
500-
path.indexOf(entry.absolute + '\\') === 0)
501-
this.dirCache.delete(path)
502-
}
503-
}
509+
if (entry.type !== 'Directory')
510+
pruneCache(this.dirCache, entry.absolute)
504511

505512
this[MKDIR](path.dirname(entry.absolute), this.dmode, er => {
506513
if (er) {
@@ -556,7 +563,7 @@ class Unpack extends Parser {
556563
}
557564

558565
[LINK] (entry, linkpath, link, done) {
559-
// XXX: get the type ('file' or 'dir') for windows
566+
// XXX: get the type ('symlink' or 'junction') for windows
560567
fs[link](linkpath, entry.absolute, er => {
561568
if (er)
562569
this[ONERROR](er, entry)
@@ -571,14 +578,8 @@ class Unpack extends Parser {
571578

572579
class UnpackSync extends Unpack {
573580
[CHECKFS] (entry) {
574-
if (entry.type !== 'Directory') {
575-
for (const path of this.dirCache.keys()) {
576-
if (path === entry.absolute ||
577-
path.indexOf(entry.absolute + '/') === 0 ||
578-
path.indexOf(entry.absolute + '\\') === 0)
579-
this.dirCache.delete(path)
580-
}
581-
}
581+
if (entry.type !== 'Directory')
582+
pruneCache(this.dirCache, entry.absolute)
582583

583584
const er = this[MKDIR](path.dirname(entry.absolute), this.dmode, neverCalled)
584585
if (er)
@@ -700,7 +701,7 @@ class UnpackSync extends Unpack {
700701

701702
[MKDIR] (dir, mode) {
702703
try {
703-
return mkdir.sync(dir, {
704+
return mkdir.sync(normPath(dir), {
704705
uid: this.uid,
705706
gid: this.gid,
706707
processUid: this.processUid,

lib/write-entry.js

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@ const Pax = require('./pax.js')
44
const Header = require('./header.js')
55
const fs = require('fs')
66
const path = require('path')
7+
const normPath = require('./normalize-windows-path.js')
8+
const stripSlash = require('./strip-trailing-slashes.js')
79

810
const prefixPath = (path, prefix) => {
911
if (!prefix)
1012
return path
11-
path = path.replace(/^\.([/\\]|$)/, '')
12-
return prefix + '/' + path
13+
path = normPath(path).replace(/^\.(\/|$)/, '')
14+
return stripSlash(prefix) + '/' + path
1315
}
1416

1517
const maxReadSize = 16 * 1024 * 1024
@@ -43,7 +45,7 @@ const WriteEntry = warner(class WriteEntry extends MiniPass {
4345
super(opt)
4446
if (typeof p !== 'string')
4547
throw new TypeError('path is required')
46-
this.path = p
48+
this.path = normPath(p)
4749
// suppress atime, ctime, uid, gid, uname, gname
4850
this.portable = !!opt.portable
4951
// until node has builtin pwnam functions, this'll have to do
@@ -87,7 +89,7 @@ const WriteEntry = warner(class WriteEntry extends MiniPass {
8789
p = p.replace(/\\/g, '/')
8890
}
8991

90-
this.absolute = opt.absolute || path.resolve(this.cwd, p)
92+
this.absolute = normPath(opt.absolute || path.resolve(this.cwd, p))
9193

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

0 commit comments

Comments
 (0)