| 'use strict'; |
| |
| var utils = require('../utils'); |
| var GenericWorker = require('../stream/GenericWorker'); |
| var utf8 = require('../utf8'); |
| var crc32 = require('../crc32'); |
| var signature = require('../signature'); |
| |
| /** |
| * Transform an integer into a string in hexadecimal. |
| * @private |
| * @param {number} dec the number to convert. |
| * @param {number} bytes the number of bytes to generate. |
| * @returns {string} the result. |
| */ |
| var decToHex = function(dec, bytes) { |
| var hex = "", i; |
| for (i = 0; i < bytes; i++) { |
| hex += String.fromCharCode(dec & 0xff); |
| dec = dec >>> 8; |
| } |
| return hex; |
| }; |
| |
| /** |
| * Generate the UNIX part of the external file attributes. |
| * @param {Object} unixPermissions the unix permissions or null. |
| * @param {Boolean} isDir true if the entry is a directory, false otherwise. |
| * @return {Number} a 32 bit integer. |
| * |
| * adapted from http://unix.stackexchange.com/questions/14705/the-zip-formats-external-file-attribute : |
| * |
| * TTTTsstrwxrwxrwx0000000000ADVSHR |
| * ^^^^____________________________ file type, see zipinfo.c (UNX_*) |
| * ^^^_________________________ setuid, setgid, sticky |
| * ^^^^^^^^^________________ permissions |
| * ^^^^^^^^^^______ not used ? |
| * ^^^^^^ DOS attribute bits : Archive, Directory, Volume label, System file, Hidden, Read only |
| */ |
| var generateUnixExternalFileAttr = function (unixPermissions, isDir) { |
| |
| var result = unixPermissions; |
| if (!unixPermissions) { |
| // I can't use octal values in strict mode, hence the hexa. |
| // 040775 => 0x41fd |
| // 0100664 => 0x81b4 |
| result = isDir ? 0x41fd : 0x81b4; |
| } |
| return (result & 0xFFFF) << 16; |
| }; |
| |
| /** |
| * Generate the DOS part of the external file attributes. |
| * @param {Object} dosPermissions the dos permissions or null. |
| * @param {Boolean} isDir true if the entry is a directory, false otherwise. |
| * @return {Number} a 32 bit integer. |
| * |
| * Bit 0 Read-Only |
| * Bit 1 Hidden |
| * Bit 2 System |
| * Bit 3 Volume Label |
| * Bit 4 Directory |
| * Bit 5 Archive |
| */ |
| var generateDosExternalFileAttr = function (dosPermissions, isDir) { |
| |
| // the dir flag is already set for compatibility |
| return (dosPermissions || 0) & 0x3F; |
| }; |
| |
| /** |
| * Generate the various parts used in the construction of the final zip file. |
| * @param {Object} streamInfo the hash with informations about the compressed file. |
| * @param {Boolean} streamedContent is the content streamed ? |
| * @param {Boolean} streamingEnded is the stream finished ? |
| * @param {number} offset the current offset from the start of the zip file. |
| * @param {String} platform let's pretend we are this platform (change platform dependents fields) |
| * @param {Function} encodeFileName the function to encode the file name / comment. |
| * @return {Object} the zip parts. |
| */ |
| var generateZipParts = function(streamInfo, streamedContent, streamingEnded, offset, platform, encodeFileName) { |
| var file = streamInfo['file'], |
| compression = streamInfo['compression'], |
| useCustomEncoding = encodeFileName !== utf8.utf8encode, |
| encodedFileName = utils.transformTo("string", encodeFileName(file.name)), |
| utfEncodedFileName = utils.transformTo("string", utf8.utf8encode(file.name)), |
| comment = file.comment, |
| encodedComment = utils.transformTo("string", encodeFileName(comment)), |
| utfEncodedComment = utils.transformTo("string", utf8.utf8encode(comment)), |
| useUTF8ForFileName = utfEncodedFileName.length !== file.name.length, |
| useUTF8ForComment = utfEncodedComment.length !== comment.length, |
| dosTime, |
| dosDate, |
| extraFields = "", |
| unicodePathExtraField = "", |
| unicodeCommentExtraField = "", |
| dir = file.dir, |
| date = file.date; |
| |
| |
| var dataInfo = { |
| crc32 : 0, |
| compressedSize : 0, |
| uncompressedSize : 0 |
| }; |
| |
| // if the content is streamed, the sizes/crc32 are only available AFTER |
| // the end of the stream. |
| if (!streamedContent || streamingEnded) { |
| dataInfo.crc32 = streamInfo['crc32']; |
| dataInfo.compressedSize = streamInfo['compressedSize']; |
| dataInfo.uncompressedSize = streamInfo['uncompressedSize']; |
| } |
| |
| var bitflag = 0; |
| if (streamedContent) { |
| // Bit 3: the sizes/crc32 are set to zero in the local header. |
| // The correct values are put in the data descriptor immediately |
| // following the compressed data. |
| bitflag |= 0x0008; |
| } |
| if (!useCustomEncoding && (useUTF8ForFileName || useUTF8ForComment)) { |
| // Bit 11: Language encoding flag (EFS). |
| bitflag |= 0x0800; |
| } |
| |
| |
| var extFileAttr = 0; |
| var versionMadeBy = 0; |
| if (dir) { |
| // dos or unix, we set the dos dir flag |
| extFileAttr |= 0x00010; |
| } |
| if(platform === "UNIX") { |
| versionMadeBy = 0x031E; // UNIX, version 3.0 |
| extFileAttr |= generateUnixExternalFileAttr(file.unixPermissions, dir); |
| } else { // DOS or other, fallback to DOS |
| versionMadeBy = 0x0014; // DOS, version 2.0 |
| extFileAttr |= generateDosExternalFileAttr(file.dosPermissions, dir); |
| } |
| |
| // date |
| // @see http://www.delorie.com/djgpp/doc/rbinter/it/52/13.html |
| // @see http://www.delorie.com/djgpp/doc/rbinter/it/65/16.html |
| // @see http://www.delorie.com/djgpp/doc/rbinter/it/66/16.html |
| |
| dosTime = date.getUTCHours(); |
| dosTime = dosTime << 6; |
| dosTime = dosTime | date.getUTCMinutes(); |
| dosTime = dosTime << 5; |
| dosTime = dosTime | date.getUTCSeconds() / 2; |
| |
| dosDate = date.getUTCFullYear() - 1980; |
| dosDate = dosDate << 4; |
| dosDate = dosDate | (date.getUTCMonth() + 1); |
| dosDate = dosDate << 5; |
| dosDate = dosDate | date.getUTCDate(); |
| |
| if (useUTF8ForFileName) { |
| // set the unicode path extra field. unzip needs at least one extra |
| // field to correctly handle unicode path, so using the path is as good |
| // as any other information. This could improve the situation with |
| // other archive managers too. |
| // This field is usually used without the utf8 flag, with a non |
| // unicode path in the header (winrar, winzip). This helps (a bit) |
| // with the messy Windows' default compressed folders feature but |
| // breaks on p7zip which doesn't seek the unicode path extra field. |
| // So for now, UTF-8 everywhere ! |
| unicodePathExtraField = |
| // Version |
| decToHex(1, 1) + |
| // NameCRC32 |
| decToHex(crc32(encodedFileName), 4) + |
| // UnicodeName |
| utfEncodedFileName; |
| |
| extraFields += |
| // Info-ZIP Unicode Path Extra Field |
| "\x75\x70" + |
| // size |
| decToHex(unicodePathExtraField.length, 2) + |
| // content |
| unicodePathExtraField; |
| } |
| |
| if(useUTF8ForComment) { |
| |
| unicodeCommentExtraField = |
| // Version |
| decToHex(1, 1) + |
| // CommentCRC32 |
| decToHex(crc32(encodedComment), 4) + |
| // UnicodeName |
| utfEncodedComment; |
| |
| extraFields += |
| // Info-ZIP Unicode Path Extra Field |
| "\x75\x63" + |
| // size |
| decToHex(unicodeCommentExtraField.length, 2) + |
| // content |
| unicodeCommentExtraField; |
| } |
| |
| var header = ""; |
| |
| // version needed to extract |
| header += "\x0A\x00"; |
| // general purpose bit flag |
| header += decToHex(bitflag, 2); |
| // compression method |
| header += compression.magic; |
| // last mod file time |
| header += decToHex(dosTime, 2); |
| // last mod file date |
| header += decToHex(dosDate, 2); |
| // crc-32 |
| header += decToHex(dataInfo.crc32, 4); |
| // compressed size |
| header += decToHex(dataInfo.compressedSize, 4); |
| // uncompressed size |
| header += decToHex(dataInfo.uncompressedSize, 4); |
| // file name length |
| header += decToHex(encodedFileName.length, 2); |
| // extra field length |
| header += decToHex(extraFields.length, 2); |
| |
| |
| var fileRecord = signature.LOCAL_FILE_HEADER + header + encodedFileName + extraFields; |
| |
| var dirRecord = signature.CENTRAL_FILE_HEADER + |
| // version made by (00: DOS) |
| decToHex(versionMadeBy, 2) + |
| // file header (common to file and central directory) |
| header + |
| // file comment length |
| decToHex(encodedComment.length, 2) + |
| // disk number start |
| "\x00\x00" + |
| // internal file attributes TODO |
| "\x00\x00" + |
| // external file attributes |
| decToHex(extFileAttr, 4) + |
| // relative offset of local header |
| decToHex(offset, 4) + |
| // file name |
| encodedFileName + |
| // extra field |
| extraFields + |
| // file comment |
| encodedComment; |
| |
| return { |
| fileRecord: fileRecord, |
| dirRecord: dirRecord |
| }; |
| }; |
| |
| /** |
| * Generate the EOCD record. |
| * @param {Number} entriesCount the number of entries in the zip file. |
| * @param {Number} centralDirLength the length (in bytes) of the central dir. |
| * @param {Number} localDirLength the length (in bytes) of the local dir. |
| * @param {String} comment the zip file comment as a binary string. |
| * @param {Function} encodeFileName the function to encode the comment. |
| * @return {String} the EOCD record. |
| */ |
| var generateCentralDirectoryEnd = function (entriesCount, centralDirLength, localDirLength, comment, encodeFileName) { |
| var dirEnd = ""; |
| var encodedComment = utils.transformTo("string", encodeFileName(comment)); |
| |
| // end of central dir signature |
| dirEnd = signature.CENTRAL_DIRECTORY_END + |
| // number of this disk |
| "\x00\x00" + |
| // number of the disk with the start of the central directory |
| "\x00\x00" + |
| // total number of entries in the central directory on this disk |
| decToHex(entriesCount, 2) + |
| // total number of entries in the central directory |
| decToHex(entriesCount, 2) + |
| // size of the central directory 4 bytes |
| decToHex(centralDirLength, 4) + |
| // offset of start of central directory with respect to the starting disk number |
| decToHex(localDirLength, 4) + |
| // .ZIP file comment length |
| decToHex(encodedComment.length, 2) + |
| // .ZIP file comment |
| encodedComment; |
| |
| return dirEnd; |
| }; |
| |
| /** |
| * Generate data descriptors for a file entry. |
| * @param {Object} streamInfo the hash generated by a worker, containing informations |
| * on the file entry. |
| * @return {String} the data descriptors. |
| */ |
| var generateDataDescriptors = function (streamInfo) { |
| var descriptor = ""; |
| descriptor = signature.DATA_DESCRIPTOR + |
| // crc-32 4 bytes |
| decToHex(streamInfo['crc32'], 4) + |
| // compressed size 4 bytes |
| decToHex(streamInfo['compressedSize'], 4) + |
| // uncompressed size 4 bytes |
| decToHex(streamInfo['uncompressedSize'], 4); |
| |
| return descriptor; |
| }; |
| |
| |
| /** |
| * A worker to concatenate other workers to create a zip file. |
| * @param {Boolean} streamFiles `true` to stream the content of the files, |
| * `false` to accumulate it. |
| * @param {String} comment the comment to use. |
| * @param {String} platform the platform to use, "UNIX" or "DOS". |
| * @param {Function} encodeFileName the function to encode file names and comments. |
| */ |
| function ZipFileWorker(streamFiles, comment, platform, encodeFileName) { |
| GenericWorker.call(this, "ZipFileWorker"); |
| // The number of bytes written so far. This doesn't count accumulated chunks. |
| this.bytesWritten = 0; |
| // The comment of the zip file |
| this.zipComment = comment; |
| // The platform "generating" the zip file. |
| this.zipPlatform = platform; |
| // the function to encode file names and comments. |
| this.encodeFileName = encodeFileName; |
| // Should we stream the content of the files ? |
| this.streamFiles = streamFiles; |
| // If `streamFiles` is false, we will need to accumulate the content of the |
| // files to calculate sizes / crc32 (and write them *before* the content). |
| // This boolean indicates if we are accumulating chunks (it will change a lot |
| // during the lifetime of this worker). |
| this.accumulate = false; |
| // The buffer receiving chunks when accumulating content. |
| this.contentBuffer = []; |
| // The list of generated directory records. |
| this.dirRecords = []; |
| // The offset (in bytes) from the beginning of the zip file for the current source. |
| this.currentSourceOffset = 0; |
| // The total number of entries in this zip file. |
| this.entriesCount = 0; |
| // the name of the file currently being added, null when handling the end of the zip file. |
| // Used for the emited metadata. |
| this.currentFile = null; |
| |
| |
| |
| this._sources = []; |
| } |
| utils.inherits(ZipFileWorker, GenericWorker); |
| |
| /** |
| * @see GenericWorker.push |
| */ |
| ZipFileWorker.prototype.push = function (chunk) { |
| |
| var currentFilePercent = chunk.meta.percent || 0; |
| var entriesCount = this.entriesCount; |
| var remainingFiles = this._sources.length; |
| |
| if(this.accumulate) { |
| this.contentBuffer.push(chunk); |
| } else { |
| this.bytesWritten += chunk.data.length; |
| |
| GenericWorker.prototype.push.call(this, { |
| data : chunk.data, |
| meta : { |
| currentFile : this.currentFile, |
| percent : entriesCount ? (currentFilePercent + 100 * (entriesCount - remainingFiles - 1)) / entriesCount : 100 |
| } |
| }); |
| } |
| }; |
| |
| /** |
| * The worker started a new source (an other worker). |
| * @param {Object} streamInfo the streamInfo object from the new source. |
| */ |
| ZipFileWorker.prototype.openedSource = function (streamInfo) { |
| this.currentSourceOffset = this.bytesWritten; |
| this.currentFile = streamInfo['file'].name; |
| |
| var streamedContent = this.streamFiles && !streamInfo['file'].dir; |
| |
| // don't stream folders (because they don't have any content) |
| if(streamedContent) { |
| var record = generateZipParts(streamInfo, streamedContent, false, this.currentSourceOffset, this.zipPlatform, this.encodeFileName); |
| this.push({ |
| data : record.fileRecord, |
| meta : {percent:0} |
| }); |
| } else { |
| // we need to wait for the whole file before pushing anything |
| this.accumulate = true; |
| } |
| }; |
| |
| /** |
| * The worker finished a source (an other worker). |
| * @param {Object} streamInfo the streamInfo object from the finished source. |
| */ |
| ZipFileWorker.prototype.closedSource = function (streamInfo) { |
| this.accumulate = false; |
| var streamedContent = this.streamFiles && !streamInfo['file'].dir; |
| var record = generateZipParts(streamInfo, streamedContent, true, this.currentSourceOffset, this.zipPlatform, this.encodeFileName); |
| |
| this.dirRecords.push(record.dirRecord); |
| if(streamedContent) { |
| // after the streamed file, we put data descriptors |
| this.push({ |
| data : generateDataDescriptors(streamInfo), |
| meta : {percent:100} |
| }); |
| } else { |
| // the content wasn't streamed, we need to push everything now |
| // first the file record, then the content |
| this.push({ |
| data : record.fileRecord, |
| meta : {percent:0} |
| }); |
| while(this.contentBuffer.length) { |
| this.push(this.contentBuffer.shift()); |
| } |
| } |
| this.currentFile = null; |
| }; |
| |
| /** |
| * @see GenericWorker.flush |
| */ |
| ZipFileWorker.prototype.flush = function () { |
| |
| var localDirLength = this.bytesWritten; |
| for(var i = 0; i < this.dirRecords.length; i++) { |
| this.push({ |
| data : this.dirRecords[i], |
| meta : {percent:100} |
| }); |
| } |
| var centralDirLength = this.bytesWritten - localDirLength; |
| |
| var dirEnd = generateCentralDirectoryEnd(this.dirRecords.length, centralDirLength, localDirLength, this.zipComment, this.encodeFileName); |
| |
| this.push({ |
| data : dirEnd, |
| meta : {percent:100} |
| }); |
| }; |
| |
| /** |
| * Prepare the next source to be read. |
| */ |
| ZipFileWorker.prototype.prepareNextSource = function () { |
| this.previous = this._sources.shift(); |
| this.openedSource(this.previous.streamInfo); |
| if (this.isPaused) { |
| this.previous.pause(); |
| } else { |
| this.previous.resume(); |
| } |
| }; |
| |
| /** |
| * @see GenericWorker.registerPrevious |
| */ |
| ZipFileWorker.prototype.registerPrevious = function (previous) { |
| this._sources.push(previous); |
| var self = this; |
| |
| previous.on('data', function (chunk) { |
| self.processChunk(chunk); |
| }); |
| previous.on('end', function () { |
| self.closedSource(self.previous.streamInfo); |
| if(self._sources.length) { |
| self.prepareNextSource(); |
| } else { |
| self.end(); |
| } |
| }); |
| previous.on('error', function (e) { |
| self.error(e); |
| }); |
| return this; |
| }; |
| |
| /** |
| * @see GenericWorker.resume |
| */ |
| ZipFileWorker.prototype.resume = function () { |
| if(!GenericWorker.prototype.resume.call(this)) { |
| return false; |
| } |
| |
| if (!this.previous && this._sources.length) { |
| this.prepareNextSource(); |
| return true; |
| } |
| if (!this.previous && !this._sources.length && !this.generatedError) { |
| this.end(); |
| return true; |
| } |
| }; |
| |
| /** |
| * @see GenericWorker.error |
| */ |
| ZipFileWorker.prototype.error = function (e) { |
| var sources = this._sources; |
| if(!GenericWorker.prototype.error.call(this, e)) { |
| return false; |
| } |
| for(var i = 0; i < sources.length; i++) { |
| try { |
| sources[i].error(e); |
| } catch(e) { |
| // the `error` exploded, nothing to do |
| } |
| } |
| return true; |
| }; |
| |
| /** |
| * @see GenericWorker.lock |
| */ |
| ZipFileWorker.prototype.lock = function () { |
| GenericWorker.prototype.lock.call(this); |
| var sources = this._sources; |
| for(var i = 0; i < sources.length; i++) { |
| sources[i].lock(); |
| } |
| }; |
| |
| module.exports = ZipFileWorker; |