Skip to content

Commit af00186

Browse files
authored
refactor: replace callback-style API with Promise-style API (#90)
BREAKING CHANGE: drops callback-style support in favor of a Promises-based API. If you still need callback-style support, see [`util.callbackify`](https://nodejs.org/dist/latest-v12.x/docs/api/util.html#util_util_callbackify_original). Additional changes: * Refactor the code so that's it's encapsulated in a class, instead of in one monolithic function (with nested functions) * More modern Node refactors * Replace `tape` with `ava` * Replace `concat-stream` with `get-stream` * Replace `rimraf` (& `fs.existsSync` usage) with `fs-extra` * The code base should have full test coverage now
1 parent 7993cb8 commit af00186

File tree

6 files changed

+318
-400
lines changed

6 files changed

+318
-400
lines changed

index.js

+135-175
Original file line numberDiff line numberDiff line change
@@ -1,215 +1,175 @@
1-
const fs = require('fs')
1+
const debug = require('debug')('extract-zip')
2+
const { createWriteStream, promises: fs } = require('fs')
3+
const getStream = require('get-stream')
24
const path = require('path')
5+
const { promisify } = require('util')
6+
const stream = require('stream')
37
const yauzl = require('yauzl')
4-
const concat = require('concat-stream')
5-
const debug = require('debug')('extract-zip')
68

7-
module.exports = function (zipPath, opts, cb) {
8-
debug('creating target directory', opts.dir)
9+
const openZip = promisify(yauzl.open)
10+
const pipeline = promisify(stream.pipeline)
911

10-
if (path.isAbsolute(opts.dir) === false) {
11-
return cb(new Error('Target directory is expected to be absolute'))
12+
class Extractor {
13+
constructor (zipPath, opts) {
14+
this.zipPath = zipPath
15+
this.opts = opts
1216
}
1317

14-
fs.mkdir(opts.dir, { recursive: true }, function (err) {
15-
if (err) return cb(err)
16-
17-
fs.realpath(opts.dir, function (err, canonicalDir) {
18-
if (err) return cb(err)
19-
20-
opts.dir = canonicalDir
21-
22-
openZip(opts)
23-
})
24-
})
25-
26-
function openZip () {
27-
debug('opening', zipPath, 'with opts', opts)
18+
async extract () {
19+
debug('opening', this.zipPath, 'with opts', this.opts)
2820

29-
yauzl.open(zipPath, { lazyEntries: true }, function (err, zipfile) {
30-
if (err) return cb(err)
21+
this.zipfile = await openZip(this.zipPath, { lazyEntries: true })
22+
this.canceled = false
3123

32-
let cancelled = false
33-
34-
zipfile.on('error', function (err) {
35-
if (err) {
36-
cancelled = true
37-
return cb(err)
38-
}
24+
return new Promise((resolve, reject) => {
25+
this.zipfile.on('error', err => {
26+
this.canceled = true
27+
reject(err)
3928
})
40-
zipfile.readEntry()
29+
this.zipfile.readEntry()
4130

42-
zipfile.on('close', function () {
43-
if (!cancelled) {
31+
this.zipfile.on('close', () => {
32+
if (!this.canceled) {
4433
debug('zip extraction complete')
45-
cb()
34+
resolve()
4635
}
4736
})
4837

49-
zipfile.on('entry', function (entry) {
38+
this.zipfile.on('entry', async entry => {
5039
/* istanbul ignore if */
51-
if (cancelled) {
52-
debug('skipping entry', entry.fileName, { cancelled: cancelled })
40+
if (this.canceled) {
41+
debug('skipping entry', entry.fileName, { cancelled: this.canceled })
5342
return
5443
}
5544

5645
debug('zipfile entry', entry.fileName)
5746

5847
if (entry.fileName.startsWith('__MACOSX/')) {
59-
zipfile.readEntry()
48+
this.zipfile.readEntry()
6049
return
6150
}
6251

63-
const destDir = path.dirname(path.join(opts.dir, entry.fileName))
52+
const destDir = path.dirname(path.join(this.opts.dir, entry.fileName))
6453

65-
fs.mkdir(destDir, { recursive: true }, function (err) {
66-
/* istanbul ignore if */
67-
if (err) {
68-
cancelled = true
69-
zipfile.close()
70-
return cb(err)
71-
}
54+
try {
55+
await fs.mkdir(destDir, { recursive: true })
7256

73-
fs.realpath(destDir, function (err, canonicalDestDir) {
74-
/* istanbul ignore if */
75-
if (err) {
76-
cancelled = true
77-
zipfile.close()
78-
return cb(err)
79-
}
80-
81-
const relativeDestDir = path.relative(opts.dir, canonicalDestDir)
82-
83-
if (relativeDestDir.split(path.sep).indexOf('..') !== -1) {
84-
cancelled = true
85-
zipfile.close()
86-
return cb(new Error('Out of bound path "' + canonicalDestDir + '" found while processing file ' + entry.fileName))
87-
}
88-
89-
extractEntry(entry, function (err) {
90-
// if any extraction fails then abort everything
91-
if (err) {
92-
cancelled = true
93-
zipfile.close()
94-
return cb(err)
95-
}
96-
debug('finished processing', entry.fileName)
97-
zipfile.readEntry()
98-
})
99-
})
100-
})
101-
})
57+
const canonicalDestDir = await fs.realpath(destDir)
58+
const relativeDestDir = path.relative(this.opts.dir, canonicalDestDir)
10259

103-
function extractEntry (entry, done) {
104-
/* istanbul ignore if */
105-
if (cancelled) {
106-
debug('skipping entry extraction', entry.fileName, { cancelled: cancelled })
107-
return setImmediate(done)
108-
}
60+
if (relativeDestDir.split(path.sep).includes('..')) {
61+
throw new Error(`Out of bound path "${canonicalDestDir}" found while processing file ${entry.fileName}`)
62+
}
10963

110-
if (opts.onEntry) {
111-
opts.onEntry(entry, zipfile)
64+
await this.extractEntry(entry)
65+
debug('finished processing', entry.fileName)
66+
this.zipfile.readEntry()
67+
} catch (err) {
68+
this.canceled = true
69+
this.zipfile.close()
70+
reject(err)
11271
}
72+
})
73+
})
74+
}
11375

114-
const dest = path.join(opts.dir, entry.fileName)
115-
116-
// convert external file attr int into a fs stat mode int
117-
let mode = (entry.externalFileAttributes >> 16) & 0xFFFF
118-
// check if it's a symlink or dir (using stat mode constants)
119-
const IFMT = 61440
120-
const IFDIR = 16384
121-
const IFLNK = 40960
122-
const symlink = (mode & IFMT) === IFLNK
123-
let isDir = (mode & IFMT) === IFDIR
76+
async extractEntry (entry) {
77+
/* istanbul ignore if */
78+
if (this.canceled) {
79+
debug('skipping entry extraction', entry.fileName, { cancelled: this.canceled })
80+
return
81+
}
82+
83+
if (this.opts.onEntry) {
84+
this.opts.onEntry(entry, this.zipfile)
85+
}
86+
87+
const dest = path.join(this.opts.dir, entry.fileName)
88+
89+
// convert external file attr int into a fs stat mode int
90+
const mode = (entry.externalFileAttributes >> 16) & 0xFFFF
91+
// check if it's a symlink or dir (using stat mode constants)
92+
const IFMT = 61440
93+
const IFDIR = 16384
94+
const IFLNK = 40960
95+
const symlink = (mode & IFMT) === IFLNK
96+
let isDir = (mode & IFMT) === IFDIR
97+
98+
// Failsafe, borrowed from jsZip
99+
if (!isDir && entry.fileName.endsWith('/')) {
100+
isDir = true
101+
}
102+
103+
// check for windows weird way of specifying a directory
104+
// https://github.com/maxogden/extract-zip/issues/13#issuecomment-154494566
105+
const madeBy = entry.versionMadeBy >> 8
106+
if (!isDir) isDir = (madeBy === 0 && entry.externalFileAttributes === 16)
107+
108+
debug('extracting entry', { filename: entry.fileName, isDir: isDir, isSymlink: symlink })
109+
110+
// reverse umask first (~)
111+
const umask = ~process.umask()
112+
// & with processes umask to override invalid perms
113+
const procMode = this.getExtractedMode(mode, isDir) & umask
114+
115+
// always ensure folders are created
116+
const destDir = isDir ? dest : path.dirname(dest)
117+
118+
const mkdirOptions = { recursive: true }
119+
if (isDir) {
120+
mkdirOptions.mode = procMode
121+
}
122+
debug('mkdir', { dir: destDir, ...mkdirOptions })
123+
await fs.mkdir(destDir, mkdirOptions)
124+
if (isDir) return
125+
126+
debug('opening read stream', dest)
127+
const readStream = await promisify(this.zipfile.openReadStream.bind(this.zipfile))(entry)
128+
129+
if (symlink) {
130+
const link = await getStream(readStream)
131+
debug('creating symlink', link, dest)
132+
await fs.symlink(link, dest)
133+
} else {
134+
await pipeline(readStream, createWriteStream(dest, { mode: procMode }))
135+
}
136+
}
124137

125-
// Failsafe, borrowed from jsZip
126-
if (!isDir && entry.fileName.slice(-1) === '/') {
127-
isDir = true
138+
getExtractedMode (entryMode, isDir) {
139+
let mode = entryMode
140+
// Set defaults, if necessary
141+
if (mode === 0) {
142+
if (isDir) {
143+
if (this.opts.defaultDirMode) {
144+
mode = parseInt(this.opts.defaultDirMode, 10)
128145
}
129146

130-
// check for windows weird way of specifying a directory
131-
// https://github.com/maxogden/extract-zip/issues/13#issuecomment-154494566
132-
const madeBy = entry.versionMadeBy >> 8
133-
if (!isDir) isDir = (madeBy === 0 && entry.externalFileAttributes === 16)
134-
135-
// if no mode then default to default modes
136-
if (mode === 0) {
137-
if (isDir) {
138-
if (opts.defaultDirMode) mode = parseInt(opts.defaultDirMode, 10)
139-
if (!mode) mode = 0o755
140-
} else {
141-
if (opts.defaultFileMode) mode = parseInt(opts.defaultFileMode, 10)
142-
if (!mode) mode = 0o644
143-
}
147+
if (!mode) {
148+
mode = 0o755
149+
}
150+
} else {
151+
if (this.opts.defaultFileMode) {
152+
mode = parseInt(this.opts.defaultFileMode, 10)
144153
}
145154

146-
debug('extracting entry', { filename: entry.fileName, isDir: isDir, isSymlink: symlink })
147-
148-
// reverse umask first (~)
149-
const umask = ~process.umask()
150-
// & with processes umask to override invalid perms
151-
const procMode = mode & umask
155+
if (!mode) {
156+
mode = 0o644
157+
}
158+
}
159+
}
152160

153-
// always ensure folders are created
154-
const destDir = isDir ? dest : path.dirname(dest)
161+
return mode
162+
}
163+
}
155164

156-
debug('mkdirp', { dir: destDir })
157-
fs.mkdir(destDir, { recursive: true }, function (err) {
158-
/* istanbul ignore if */
159-
if (err) {
160-
debug('mkdirp error', destDir, { error: err })
161-
cancelled = true
162-
return done(err)
163-
}
165+
module.exports = async function (zipPath, opts) {
166+
debug('creating target directory', opts.dir)
164167

165-
if (isDir) return done()
166-
167-
debug('opening read stream', dest)
168-
zipfile.openReadStream(entry, function (err, readStream) {
169-
/* istanbul ignore if */
170-
if (err) {
171-
debug('openReadStream error', err)
172-
cancelled = true
173-
return done(err)
174-
}
175-
176-
readStream.on('error', function (err) {
177-
/* istanbul ignore next */
178-
console.log('read err', err)
179-
})
180-
181-
if (symlink) writeSymlink()
182-
else writeStream()
183-
184-
function writeStream () {
185-
const writeStream = fs.createWriteStream(dest, { mode: procMode })
186-
readStream.pipe(writeStream)
187-
188-
writeStream.on('finish', function () {
189-
done()
190-
})
191-
192-
writeStream.on('error', /* istanbul ignore next */ function (err) {
193-
debug('write error', { error: err })
194-
cancelled = true
195-
return done(err)
196-
})
197-
}
198-
199-
// AFAICT the content of the symlink file itself is the symlink target filename string
200-
function writeSymlink () {
201-
readStream.pipe(concat(function (data) {
202-
const link = data.toString()
203-
debug('creating symlink', link, dest)
204-
fs.symlink(link, dest, function (err) {
205-
if (err) cancelled = true
206-
done(err)
207-
})
208-
}))
209-
}
210-
})
211-
})
212-
}
213-
})
168+
if (!path.isAbsolute(opts.dir)) {
169+
throw new Error('Target directory is expected to be absolute')
214170
}
171+
172+
await fs.mkdir(opts.dir, { recursive: true })
173+
opts.dir = await fs.realpath(opts.dir)
174+
return new Extractor(zipPath, opts).extract()
215175
}

package.json

+7-6
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@
77
"extract-zip": "cli.js"
88
},
99
"scripts": {
10-
"coverage": "nyc node test/test.js",
10+
"ava": "ava",
11+
"coverage": "nyc ava",
1112
"lint": "standard",
12-
"test": "node test/test.js"
13+
"test": "npm run lint && ava"
1314
},
1415
"files": [
1516
"*.js"
@@ -26,15 +27,15 @@
2627
"node": ">= 10.12.0"
2728
},
2829
"dependencies": {
29-
"concat-stream": "^2.0.0",
3030
"debug": "^4.1.1",
31+
"get-stream": "^5.1.0",
3132
"yauzl": "^2.10.0"
3233
},
3334
"devDependencies": {
35+
"ava": "^3.5.1",
36+
"fs-extra": "^9.0.0",
3437
"nyc": "^15.0.0",
35-
"rimraf": "^3.0.2",
36-
"standard": "^14.3.3",
37-
"tape": "^4.2.0"
38+
"standard": "^14.3.3"
3839
},
3940
"directories": {
4041
"test": "test"

0 commit comments

Comments
 (0)