/** * Archiver Core * * @ignore * @license [MIT]{@link https://github.com/archiverjs/node-archiver/blob/master/LICENSE} * @copyright (c) 2012-2014 Chris Talkington, contributors. */ var fs = require('fs'); var glob = require('glob'); var async = require('async'); var _ = require('lodash'); var util = require('archiver-utils'); var inherits = require('util').inherits; var Transform = require('readable-stream').Transform; var win32 = process.platform === 'win32'; /** * @constructor * @param {String} format The archive format to use. * @param {(CoreOptions|TransformOptions)} options See also {@link ZipOptions} and {@link TarOptions}. */ var Archiver = function(format, options) { if (!(this instanceof Archiver)) { return new Archiver(format, options); } if (typeof format !== 'string') { options = format; format = 'zip'; } options = this.options = util.defaults(options, { highWaterMark: 1024 * 1024, statConcurrency: 4 }); Transform.call(this, options); this._entries = []; this._format = false; this._module = false; this._pending = 0; this._pointer = 0; this._queue = async.queue(this._onQueueTask.bind(this), 1); this._queue.drain = this._onQueueDrain.bind(this); this._statQueue = async.queue(this._onStatQueueTask.bind(this), options.statConcurrency); this._state = { aborted: false, finalize: false, finalizing: false, finalized: false, modulePiped: false }; this._streams = []; }; inherits(Archiver, Transform); /** * Internal logic for `abort`. * * @private * @return void */ Archiver.prototype._abort = function() { this._state.aborted = true; this._queue.kill(); this._statQueue.kill(); if (this._queue.idle()) { this._shutdown(); } }; /** * Internal helper for appending files. * * @private * @param {String} filepath The source filepath. * @param {EntryData} data The entry data. * @return void */ Archiver.prototype._append = function(filepath, data) { data = data || {}; var task = { source: null, filepath: filepath }; if (!data.name) { data.name = filepath; } data.sourcePath = filepath; task.data = data; if (data.stats && data.stats instanceof fs.Stats) { task = this._updateQueueTaskWithStats(task, data.stats); this._queue.push(task); } else { this._statQueue.push(task); } }; /** * Internal logic for `finalize`. * * @private * @return void */ Archiver.prototype._finalize = function() { if (this._state.finalizing || this._state.finalized || this._state.aborted) { return; } this._state.finalizing = true; this._moduleFinalize(); this._state.finalizing = false; this._state.finalized = true; }; /** * Checks the various state variables to determine if we can `finalize`. * * @private * @return {Boolean} */ Archiver.prototype._maybeFinalize = function() { if (this._state.finalizing || this._state.finalized || this._state.aborted) { return false; } if (this._state.finalize && this._pending === 0 && this._queue.idle() && this._statQueue.idle()) { this._finalize(); return true; } return false; }; /** * Appends an entry to the module. * * @private * @fires Archiver#entry * @param {(Buffer|Stream)} source * @param {EntryData} data * @param {Function} callback * @return void */ Archiver.prototype._moduleAppend = function(source, data, callback) { if (this._state.aborted) { callback(); return; } this._module.append(source, data, function(err) { this._task = null; if (this._state.aborted) { this._shutdown(); return; } if (err) { this.emit('error', err); setImmediate(callback); return; } /** * Fires when the entry's input has been processed and appended to the archive. * * @event Archiver#entry * @type {EntryData} */ this.emit('entry', data); this._entries.push(data); setImmediate(callback); }.bind(this)); }; /** * Finalizes the module. * * @private * @return void */ Archiver.prototype._moduleFinalize = function() { if (typeof this._module.finalize === 'function') { this._module.finalize(); } else if (typeof this._module.end === 'function') { this._module.end(); } else { this.emit('error', new Error('module: no suitable finalize/end method found')); return; } }; /** * Pipes the module to our internal stream with error bubbling. * * @private * @return void */ Archiver.prototype._modulePipe = function() { this._module.on('error', this._onModuleError.bind(this)); this._module.pipe(this); this._state.modulePiped = true; }; /** * Determines if the current module supports a defined feature. * * @private * @param {String} key * @return {Boolean} */ Archiver.prototype._moduleSupports = function(key) { if (!this._module.supports || !this._module.supports[key]) { return false; } return this._module.supports[key]; }; /** * Unpipes the module from our internal stream. * * @private * @return void */ Archiver.prototype._moduleUnpipe = function() { this._module.unpipe(this); this._state.modulePiped = false; }; /** * Normalizes entry data with fallbacks for key properties. * * @private * @param {Object} data * @param {fs.Stats} stats * @return {Object} */ Archiver.prototype._normalizeEntryData = function(data, stats) { data = util.defaults(data, { type: 'file', name: null, date: null, mode: null, prefix: null, sourcePath: null, stats: false }); if (stats && data.stats === false) { data.stats = stats; } var isDir = data.type === 'directory'; if (data.name) { if (typeof data.prefix === 'string' && '' !== data.prefix) { data.name = data.prefix + '/' + data.name; data.prefix = null; } data.name = util.sanitizePath(data.name); if (data.name.slice(-1) === '/') { isDir = true; data.type = 'directory'; } else if (isDir) { data.name += '/'; } } // 511 === 0777; 493 === 0755; 438 === 0666; 420 === 0644 if (typeof data.mode === 'number') { if (win32) { data.mode &= 511; } else { data.mode &= 4095 } } else if (data.stats && data.mode === null) { if (win32) { data.mode = data.stats.mode & 511; } else { data.mode = data.stats.mode & 4095; } // stat isn't reliable on windows; force 0755 for dir if (win32 && isDir) { data.mode = 493; } } else if (data.mode === null) { data.mode = isDir ? 493 : 420; } if (data.stats && data.date === null) { data.date = data.stats.mtime; } else { data.date = util.dateify(data.date); } return data; }; /** * Error listener that re-emits error on to our internal stream. * * @private * @param {Error} err * @return void */ Archiver.prototype._onModuleError = function(err) { this.emit('error', err); }; /** * Checks the various state variables after queue has drained to determine if * we need to `finalize`. * * @private * @return void */ Archiver.prototype._onQueueDrain = function() { if (this._state.finalizing || this._state.finalized || this._state.aborted) { return; } if (this._state.finalize && this._pending === 0 && this._queue.idle() && this._statQueue.idle()) { this._finalize(); return; } }; /** * Appends each queue task to the module. * * @private * @param {Object} task * @param {Function} callback * @return void */ Archiver.prototype._onQueueTask = function(task, callback) { if (this._state.finalizing || this._state.finalized || this._state.aborted) { callback(); return; } this._task = task; this._moduleAppend(task.source, task.data, callback); }; /** * Performs a file stat and reinjects the task back into the queue. * * @private * @param {Object} task * @param {Function} callback * @return void */ Archiver.prototype._onStatQueueTask = function(task, callback) { if (this._state.finalizing || this._state.finalized || this._state.aborted) { callback(); return; } fs.stat(task.filepath, function(err, stats) { if (this._state.aborted) { setImmediate(callback); return; } if (err) { this.emit('error', err); setImmediate(callback); return; } task = this._updateQueueTaskWithStats(task, stats); if (task.source !== null) { this._queue.push(task); setImmediate(callback); } else { this.emit('error', new Error('unsupported entry: ' + task.filepath)); setImmediate(callback); return; } }.bind(this)); }; /** * Unpipes the module and ends our internal stream. * * @private * @return void */ Archiver.prototype._shutdown = function() { this._moduleUnpipe(); this.end(); }; /** * Tracks the bytes emitted by our internal stream. * * @private * @param {Buffer} chunk * @param {String} encoding * @param {Function} callback * @return void */ Archiver.prototype._transform = function(chunk, encoding, callback) { if (chunk) { this._pointer += chunk.length; } callback(null, chunk); }; /** * Updates and normalizes a queue task using stats data. * * @private * @param {Object} task * @param {fs.Stats} stats * @return {Object} */ Archiver.prototype._updateQueueTaskWithStats = function(task, stats) { if (stats.isFile()) { task.data.type = 'file'; task.data.sourceType = 'stream'; task.source = util.lazyReadStream(task.filepath); } else if (stats.isDirectory() && this._moduleSupports('directory')) { task.data.name = util.trailingSlashIt(task.data.name); task.data.type = 'directory'; task.data.sourcePath = util.trailingSlashIt(task.filepath); task.data.sourceType = 'buffer'; task.source = new Buffer(0); } else { return task; } task.data = this._normalizeEntryData(task.data, stats); return task; }; /** * Aborts the archiving process, taking a best-effort approach, by: * * - removing any pending queue tasks * - allowing any active queue workers to finish * - detaching internal module pipes * - ending both sides of the Transform stream * * It will NOT drain any remaining sources. * * @return {this} */ Archiver.prototype.abort = function() { if (this._state.aborted || this._state.finalized) { return this; } this._abort(); return this; }; /** * Appends an input source (text string, buffer, or stream) to the instance. * * When the instance has received, processed, and emitted the input, the `entry` * event is fired. * * @fires Archiver#entry * @param {(Buffer|Stream|String)} source The input source. * @param {EntryData} data See also {@link ZipEntryData} and {@link TarEntryData}. * @return {this} */ Archiver.prototype.append = function(source, data) { if (this._state.finalize || this._state.aborted) { this.emit('error', new Error('append: queue closed')); return this; } data = this._normalizeEntryData(data); if (typeof data.name !== 'string' || data.name.length === 0) { this.emit('error', new Error('append: entry name must be a non-empty string value')); return this; } if (data.type === 'directory' && !this._moduleSupports('directory')) { this.emit('error', new Error('append: entries of "directory" type not currently supported by this module')); return this; } source = util.normalizeInputSource(source); if (Buffer.isBuffer(source)) { data.sourceType = 'buffer'; } else if (util.isStream(source)) { data.sourceType = 'stream'; } else { this.emit('error', new Error('append: input source must be valid Stream or Buffer instance')); return this; } this._queue.push({ data: data, source: source }); return this; }; /** * Appends multiple entries from passed array of src-dest mappings. * * A [lazystream]{@link https://github.com/jpommerening/node-lazystream} wrapper is * used to prevent issues with open file limits. * * @deprecated 0.21.0 * @param {Object[]} mappings * @param {(EntryData|Function)} mappings[].data See also {@link ZipEntryData} * and {@link TarEntryData}. * @param {(String|Array)} mappings[].src Pattern(s) to match, relative to the `cwd`. * @param {String} mappings[].dest Destination path prefix. * @param {Boolean} mappings[].expand Process a dynamic src-dest file mapping. * @param {String} mappings[].cwd All `src` matches are relative to (but don't include) * this path. requires `expand`. * @param {String} mappings[].ext Replace any existing extension with this value in * generated `dest` paths. requires `expand`. * @param {String} mappings[].extDot Used to indicate where the period indicating * the extension is located. requires `expand`. * @param {String} mappings[].flatten Remove all path parts from generated `dest` * paths. requires `expand`. * @param {*} mappings[].* See [node-glob]{@link https://github.com/isaacs/node-glob#properties} * and [minimatch]{@link https://github.com/isaacs/minimatch#properties} documentation * for additional properties. * @return {this} */ Archiver.prototype.bulk = function(mappings) { if (this._state.finalize || this._state.aborted) { this.emit('error', new Error('bulk: queue closed')); return this; } if (!Array.isArray(mappings)) { mappings = [mappings]; } var self = this; var files = util.file.normalizeFilesArray(mappings); files.forEach(function(file){ var isExpandedPair = file.orig.expand || false; var data = {}; var dataFunction = false; if (typeof file.data === 'function') { dataFunction = file.data; } else if (typeof file.data === 'object') { data = file.data; } file.src.forEach(function(filepath) { var entryData = _.extend({}, data); var entryName = isExpandedPair ? file.dest : (file.dest || '') + '/' + filepath; entryData.name = util.sanitizePath(entryName); if (entryData.name === '.') { return; } try { if (dataFunction) { entryData = dataFunction(entryData); if (typeof entryData !== 'object') { throw new Error('bulk: invalid data returned from custom function'); } } } catch(e) { self.emit('error', e); return; } self._append(filepath, entryData); }); }); return this; }; /** * Appends a directory and its files, recursively, given its dirpath. * * @param {String} dirpath The source directory path. * @param {String} destpath The destination path within the archive. * @param {(EntryData|Function)} data See also [ZipEntryData]{@link ZipEntryData} and * [TarEntryData]{@link TarEntryData}. * @return {this} */ Archiver.prototype.directory = function(dirpath, destpath, data) { if (this._state.finalize || this._state.aborted) { this.emit('error', new Error('directory: queue closed')); return this; } if (typeof dirpath !== 'string' || dirpath.length === 0) { this.emit('error', new Error('directory: dirpath must be a non-empty string value')); return this; } this._pending++; if (destpath === false) { destpath = ''; } else if (typeof destpath !== 'string'){ destpath = dirpath; } var dataFunction = false; if (typeof data === 'function') { dataFunction = data; data = {}; } else if (typeof data !== 'object') { data = {}; } var self = this; util.walkdir(dirpath, function(err, results) { if (err) { self.emit('error', err); } else { results.forEach(function(file) { var entryData = _.extend({}, data); entryData.name = file.relative; entryData.prefix = destpath; entryData.stats = file.stats; try { if (dataFunction) { entryData = dataFunction(entryData); if (typeof entryData !== 'object') { throw new Error('directory: invalid data returned from custom function'); } } } catch(e) { self.emit('error', e); return; } self._append(file.path, entryData); }); } self._pending--; self._maybeFinalize(); }); return this; }; /** * Appends a file given its filepath using a * [lazystream]{@link https://github.com/jpommerening/node-lazystream} wrapper to * prevent issues with open file limits. * * When the instance has received, processed, and emitted the file, the `entry` * event is fired. * * @param {String} filepath The source filepath. * @param {EntryData} data See also [ZipEntryData]{@link ZipEntryData} and * [TarEntryData]{@link TarEntryData}. * @return {this} */ Archiver.prototype.file = function(filepath, data) { if (this._state.finalize || this._state.aborted) { this.emit('error', new Error('file: queue closed')); return this; } if (typeof filepath !== 'string' || filepath.length === 0) { this.emit('error', new Error('file: filepath must be a non-empty string value')); return this; } this._append(filepath, data); return this; }; /** * Appends multiple files that match a glob pattern. * * @param {String} pattern The [glob pattern]{@link https://github.com/isaacs/node-glob#glob-primer} to match. * @param {Object} options See [node-glob]{@link https://github.com/isaacs/node-glob#options}. * @param {EntryData} data See also [ZipEntryData]{@link ZipEntryData} and * [TarEntryData]{@link TarEntryData}. * @return {this} */ Archiver.prototype.glob = function(pattern, options, data) { this._pending++; options = util.defaults(options, { stat: false }); var globber = glob(pattern, options, function(err, files) { if (err) { this.emit('error', err); return this; } files.forEach(function(file) { entryData = _.extend({}, data); if (options.cwd) { entryData.name = file; file = globber._makeAbs(file); } this._append(file, entryData); }, this); this._pending--; this._maybeFinalize(); }.bind(this)); return this; }; /** * Finalizes the instance and prevents further appending to the archive * structure (queue will continue til drained). * * The `end`, `close` or `finish` events on the destination stream may fire * right after calling this method so you should set listeners beforehand to * properly detect stream completion. * * @return {this} */ Archiver.prototype.finalize = function() { if (this._state.aborted) { this.emit('error', new Error('finalize: archive was aborted')); return this; } if (this._state.finalize) { this.emit('error', new Error('finalize: archive already finalizing')); return this; } this._state.finalize = true; if (this._pending === 0 && this._queue.idle() && this._statQueue.idle()) { this._finalize(); } return this; }; /** * Sets the module format name used for archiving. * * @param {String} format The name of the format. * @return {this} */ Archiver.prototype.setFormat = function(format) { if (this._format) { this.emit('error', new Error('format: archive format already set')); return this; } this._format = format; return this; }; /** * Sets the module used for archiving. * * @param {Function} module The function for archiver to interact with. * @return {this} */ Archiver.prototype.setModule = function(module) { if (this._state.aborted) { this.emit('error', new Error('module: archive was aborted')); return this; } if (this._state.module) { this.emit('error', new Error('module: module already set')); return this; } this._module = module; this._modulePipe(); return this; }; /** * Returns the current length (in bytes) that has been emitted. * * @return {Number} */ Archiver.prototype.pointer = function() { return this._pointer; }; /** * Middleware-like helper that has yet to be fully implemented. * * @private * @param {Function} plugin * @return {this} */ Archiver.prototype.use = function(plugin) { this._streams.push(plugin); return this; }; module.exports = Archiver; /** * @typedef {Object} CoreOptions * @global * @property {Number} [statConcurrency=4] Sets the number of workers used to * process the internal fs stat queue. */ /** * @typedef {Object} TransformOptions * @property {Boolean} [allowHalfOpen=true] If set to false, then the stream * will automatically end the readable side when the writable side ends and vice * versa. * @property {Boolean} [readableObjectMode=false] Sets objectMode for readable * side of the stream. Has no effect if objectMode is true. * @property {Boolean} [writableObjectMode=false] Sets objectMode for writable * side of the stream. Has no effect if objectMode is true. * @property {Boolean} [decodeStrings=true] Whether or not to decode strings * into Buffers before passing them to _write(). `Writable` * @property {String} [encoding=NULL] If specified, then buffers will be decoded * to strings using the specified encoding. `Readable` * @property {Number} [highWaterMark=16kb] The maximum number of bytes to store * in the internal buffer before ceasing to read from the underlying resource. * `Readable` `Writable` * @property {Boolean} [objectMode=false] Whether this stream should behave as a * stream of objects. Meaning that stream.read(n) returns a single value instead * of a Buffer of size n. `Readable` `Writable` */ /** * @typedef {Object} EntryData * @property {String} name Sets the entry name including internal path. * @property {(String|Date)} [date=NOW()] Sets the entry date. * @property {Number} [mode=D:0755/F:0644] Sets the entry permissions. * @property {String} [prefix] Sets a path prefix for the entry name. Useful * when working with methods like `directory` or `glob`. * @property {fs.Stats} [stats] Sets the fs stat data for this entry allowing * for reduction of fs stat calls when stat data is already known. */