diff --git a/lib/cli/writer.js b/lib/cli/writer.js index ccc6bff4..cfc13c83 100644 --- a/lib/cli/writer.js +++ b/lib/cli/writer.js @@ -17,11 +17,11 @@ 'use strict'; const imageWrite = require('etcher-image-write'); -const imageStream = require('etcher-image-stream'); const Bluebird = require('bluebird'); const fs = Bluebird.promisifyAll(require('fs')); const os = require('os'); const unmount = require('./unmount'); +const imageStream = require('../image-stream'); /** * @summary Write an image to a disk drive diff --git a/lib/gui/models/supported-formats.js b/lib/gui/models/supported-formats.js index c096acb5..e1d7bd41 100644 --- a/lib/gui/models/supported-formats.js +++ b/lib/gui/models/supported-formats.js @@ -23,7 +23,7 @@ const angular = require('angular'); const _ = require('lodash'); const path = require('path'); -const imageStream = require('etcher-image-stream'); +const imageStream = require('../../image-stream'); const MODULE_NAME = 'Etcher.Models.SupportedFormats'; const SupportedFormats = angular.module(MODULE_NAME, []); diff --git a/lib/gui/os/dialog/services/dialog.js b/lib/gui/os/dialog/services/dialog.js index d9db02fa..663b3bbe 100644 --- a/lib/gui/os/dialog/services/dialog.js +++ b/lib/gui/os/dialog/services/dialog.js @@ -17,8 +17,8 @@ 'use strict'; const _ = require('lodash'); -const imageStream = require('etcher-image-stream'); const electron = require('electron'); +const imageStream = require('../../../../image-stream'); module.exports = function($q, SupportedFormatsModel) { diff --git a/lib/image-stream/archive-hooks/zip.js b/lib/image-stream/archive-hooks/zip.js new file mode 100644 index 00000000..9598254c --- /dev/null +++ b/lib/image-stream/archive-hooks/zip.js @@ -0,0 +1,112 @@ +/* + * Copyright 2016 resin.io + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const Bluebird = require('bluebird'); +const _ = require('lodash'); +const StreamZip = require('node-stream-zip'); +const yauzl = Bluebird.promisifyAll(require('yauzl')); + +/** + * @summary Get all archive entries + * @function + * @public + * + * @param {String} archive - archive path + * @fulfil {Object[]} - archive entries + * @returns {Promise} + * + * @example + * zip.getEntries('path/to/my.zip').then((entries) => { + * entries.forEach((entry) => { + * console.log(entry.name); + * console.log(entry.size); + * }); + * }); + */ +exports.getEntries = (archive) => { + return new Bluebird((resolve, reject) => { + const zip = new StreamZip({ + file: archive, + storeEntries: true + }); + + zip.on('error', reject); + + zip.on('ready', () => { + return resolve(_.chain(zip.entries()) + .omitBy((entry) => { + return entry.size === 0; + }) + .map((metadata) => { + return { + name: metadata.name, + size: metadata.size + }; + }) + .value()); + }); + }); +}; + +/** + * @summary Extract a file from an archive + * @function + * @public + * + * @param {String} archive - archive path + * @param {String[]} entries - archive entries + * @param {String} file - archive file + * @fulfil {ReadableStream} file + * @returns {Promise} + * + * @example + * zip.getEntries('path/to/my.zip').then((entries) => { + * return zip.extractFile('path/to/my.zip', entries, 'my/file'); + * }).then((stream) => { + * stream.pipe('...'); + * }); + */ +exports.extractFile = (archive, entries, file) => { + return new Bluebird((resolve, reject) => { + if (!_.find(entries, { + name: file + })) { + throw new Error(`Invalid entry: ${file}`); + } + + yauzl.openAsync(archive, { + lazyEntries: true + }).then((zipfile) => { + zipfile.readEntry(); + + zipfile.on('entry', (entry) => { + if (entry.fileName !== file) { + return zipfile.readEntry(); + } + + zipfile.openReadStream(entry, (error, readStream) => { + if (error) { + return reject(error); + } + + return resolve(readStream); + }); + }); + }).catch(reject); + }); +}; diff --git a/lib/image-stream/archive.js b/lib/image-stream/archive.js new file mode 100644 index 00000000..1f48a320 --- /dev/null +++ b/lib/image-stream/archive.js @@ -0,0 +1,198 @@ +/* + * Copyright 2016 resin.io + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const path = require('path'); +const Bluebird = require('bluebird'); +const rindle = require('rindle'); +const _ = require('lodash'); +const PassThroughStream = require('stream').PassThrough; +const supportedFileTypes = require('./supported'); + +/** + * @summary Archive metadata base path + * @constant + * @private + * @type {String} + */ +const ARCHIVE_METADATA_BASE_PATH = '.meta'; + +/** + * @summary Image extensions + * @constant + * @private + * @type {String[]} + */ +const IMAGE_EXTENSIONS = _.reduce(supportedFileTypes, (accumulator, file) => { + if (file.type === 'image') { + accumulator.push(file.extension); + } + + return accumulator; +}, []); + +/** + * @summary Extract entry by path + * @function + * @private + * + * @param {String} archive - archive + * @param {String} filePath - entry file path + * @param {Object} options - options + * @param {Object} options.hooks - archive hooks + * @param {Object[]} options.entries - archive entries + * @param {*} [options.default] - entry default value + * @fulfil {*} contents + * @returns {Promise} + * + * extractEntryByPath('my/archive.zip', '_info/logo.svg', { + * hooks: { ... }, + * entries: [ ... ], + * default: '' + * }).then((contents) => { + * console.log(contents); + * }); + */ +const extractEntryByPath = (archive, filePath, options) => { + const fileEntry = _.find(options.entries, (entry) => { + return _.chain(entry.name) + .split('/') + .tail() + .join('/') + .value() === filePath; + }); + + if (!fileEntry) { + return Bluebird.resolve(options.default); + } + + return options.hooks.extractFile(archive, options.entries, fileEntry.name) + .then(rindle.extract); +}; + +/** + * @summary Extract archive metadata + * @function + * @private + * + * @param {String} archive - archive + * @param {String} basePath - metadata base path + * @param {Object} options - options + * @param {Object[]} options.entries - archive entries + * @param {Object} options.hooks - archive hooks + * @fulfil {Object} - metadata + * @returns {Promise} + * + * extractArchiveMetadata('my/archive.zip', '.meta', { + * hooks: { ... }, + * entries: [ ... ] + * }).then((metadata) => { + * console.log(metadata); + * }); + */ +const extractArchiveMetadata = (archive, basePath, options) => { + return Bluebird.props({ + logo: extractEntryByPath(archive, `${basePath}/logo.svg`, options), + instructions: extractEntryByPath(archive, `${basePath}/instructions.markdown`, options), + bmap: extractEntryByPath(archive, `${basePath}/image.bmap`, options), + manifest: _.attempt(() => { + return extractEntryByPath(archive, `${basePath}/manifest.json`, { + hooks: options.hooks, + entries: options.entries, + default: '{}' + }).then((manifest) => { + try { + return JSON.parse(manifest); + } catch (parseError) { + const error = new Error('Invalid archive manifest.json'); + error.description = 'The archive manifest.json file is not valid JSON.'; + throw error; + } + }); + }) + }).then((results) => { + return { + name: results.manifest.name, + version: results.manifest.version, + url: results.manifest.url, + supportUrl: results.manifest.supportUrl, + releaseNotesUrl: results.manifest.releaseNotesUrl, + checksumType: results.manifest.checksumType, + checksum: results.manifest.checksum, + bytesToZeroOutFromTheBeginning: results.manifest.bytesToZeroOutFromTheBeginning, + recommendedDriveSize: results.manifest.recommendedDriveSize, + logo: results.logo, + bmap: results.bmap, + instructions: results.instructions + }; + }); +}; + +/** + * @summary Extract image from archive + * @function + * @public + * + * @param {String} archive - archive path + * @param {Object} hooks - archive hooks + * @param {Function} hooks.getEntries - get entries hook + * @param {Function} hooks.extractFile - extract file hook + * @fulfil {Object} image metadata + * @returns {Promise} + * + * @example + * archive.extractImage('path/to/my/archive.zip', { + * getEntries: (archive) => { + * return [ ..., ..., ... ]; + * }, + * extractFile: (archive, entries, file) => { + * ... + * } + * }).then((image) => { + * image.stream.pipe(image.transform).pipe(...); + * }); + */ +exports.extractImage = (archive, hooks) => { + return hooks.getEntries(archive).then((entries) => { + + const imageEntries = _.filter(entries, (entry) => { + const extension = path.extname(entry.name).slice(1); + return _.includes(IMAGE_EXTENSIONS, extension); + }); + + if (imageEntries.length !== 1) { + const error = new Error('Invalid archive image'); + error.description = 'The archive image should contain one and only one top image file.'; + throw error; + } + + const imageEntry = _.first(imageEntries); + + return Bluebird.props({ + imageStream: hooks.extractFile(archive, entries, imageEntry.name), + metadata: extractArchiveMetadata(archive, ARCHIVE_METADATA_BASE_PATH, { + entries, + hooks + }) + }).then((results) => { + results.metadata.stream = results.imageStream; + results.metadata.size = imageEntry.size; + results.metadata.transform = new PassThroughStream(); + return results.metadata; + }); + }); +}; diff --git a/lib/image-stream/handlers.js b/lib/image-stream/handlers.js new file mode 100644 index 00000000..e64e0149 --- /dev/null +++ b/lib/image-stream/handlers.js @@ -0,0 +1,130 @@ +/* + * Copyright 2016 resin.io + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const Bluebird = require('bluebird'); +const fs = Bluebird.promisifyAll(require('fs')); +const PassThroughStream = require('stream').PassThrough; +const lzma = Bluebird.promisifyAll(require('lzma-native')); +const zlib = require('zlib'); +const unbzip2Stream = require('unbzip2-stream'); +const gzipUncompressedSize = Bluebird.promisifyAll(require('gzip-uncompressed-size')); +const archive = require('./archive'); +const zipArchiveHooks = require('./archive-hooks/zip'); + +/** + * @summary Image handlers + * @namespace handlers + * @public + */ +module.exports = { + + /** + * @summary Handle BZ2 compressed images + * @function + * @public + * @memberof handlers + * + * @param {String} file - file path + * @fulfil {Object} - image metadata + * @returns {Promise} + */ + 'application/x-bzip2': (file) => { + return Bluebird.props({ + stream: fs.createReadStream(file), + size: fs.statAsync(file).get('size'), + transform: Bluebird.resolve(unbzip2Stream()) + }); + }, + + /** + * @summary Handle GZ compressed images + * @function + * @public + * @memberof handlers + * + * @param {String} file - file path + * @fulfil {Object} - image metadata + * @returns {Promise} + */ + 'application/gzip': (file) => { + return Bluebird.props({ + stream: fs.createReadStream(file), + size: fs.statAsync(file).get('size'), + estimatedUncompressedSize: gzipUncompressedSize.fromFileAsync(file), + transform: Bluebird.resolve(zlib.createGunzip()) + }); + }, + + /** + * @summary Handle XZ compressed images + * @function + * @public + * @memberof handlers + * + * @param {String} file - file path + * @fulfil {Object} - image metadata + * @returns {Promise} + */ + 'application/x-xz': (file) => { + return fs.openAsync(file, 'r').then((fileDescriptor) => { + return lzma.parseFileIndexFDAsync(fileDescriptor).tap(() => { + return fs.closeAsync(fileDescriptor); + }); + }).then((metadata) => { + return { + stream: fs.createReadStream(file) + .pipe(lzma.createDecompressor()), + size: metadata.uncompressedSize, + transform: new PassThroughStream() + }; + }); + }, + + /** + * @summary Handle ZIP compressed images + * @function + * @public + * @memberof handlers + * + * @param {String} file - file path + * @fulfil {Object} - image metadata + * @returns {Promise} + */ + 'application/zip': (file) => { + return archive.extractImage(file, zipArchiveHooks); + }, + + /** + * @summary Handle plain uncompressed images + * @function + * @public + * @memberof handlers + * + * @param {String} file - file path + * @fulfil {Object} - image metadata + * @returns {Promise} + */ + 'application/octet-stream': (file) => { + return Bluebird.props({ + stream: fs.createReadStream(file), + size: fs.statAsync(file).get('size'), + transform: Bluebird.resolve(new PassThroughStream()) + }); + } + +}; diff --git a/lib/image-stream/index.js b/lib/image-stream/index.js new file mode 100644 index 00000000..faa60a93 --- /dev/null +++ b/lib/image-stream/index.js @@ -0,0 +1,120 @@ +/* + * Copyright 2016 resin.io + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const _ = require('lodash'); +const Bluebird = require('bluebird'); +const utils = require('./utils'); +const handlers = require('./handlers'); +const supportedFileTypes = require('./supported'); + +/** + * @summary Get an image stream from a file + * @function + * @public + * + * @description + * This function resolves an object containing the following properties: + * + * - `Number size`: The input file size. + * + * - `ReadableStream stream`: The input file stream. + * + * - `TransformStream transform`: A transform stream that performs any + * needed transformation to get the image out of the source input file + * (for example, decompression). + * + * The purpose of separating the above components is to handle cases like + * showing a progress bar when you can't know the final uncompressed size. + * + * In such case, you can pipe the `stream` through a progress stream using + * the input file `size`, and apply the `transform` after the progress stream. + * + * @param {String} file - file path + * @fulfil {Object} - image stream details + * @returns {Promise} + * + * @example + * const imageStream = require('./lib/image-stream'); + * + * imageStream.getFromFilePath('path/to/rpi.img.xz').then((image) => { + * image.stream + * .pipe(image.transform) + * .pipe(fs.createWriteStream('/dev/disk2')); + * }); + */ +exports.getFromFilePath = (file) => { + return Bluebird.try(() => { + const type = utils.getArchiveMimeType(file); + + if (!handlers[type]) { + throw new Error('Invalid image'); + } + + return handlers[type](file); + }).then((image) => { + return _.omitBy(image, _.isUndefined); + }); +}; + +/** + * @summary Get image metadata + * @function + * @public + * + * @description + * This function is useful to determine the final size of an image + * after decompression or any other needed transformation, as well as + * other relevent metadata, if any. + * + * **NOTE:** This function is known to output incorrect size results for + * `bzip2`. For this compression format, this function will simply + * return the size of the compressed file. + * + * @param {String} file - file path + * @fulfil {Object} - image metadata + * @returns {Promise} + * + * @example + * const imageStream = require('./lib/image-stream'); + * + * imageStream.getImageMetadata('path/to/rpi.img.xz').then((metadata) => { + * console.log(`The image display name is: ${metada.name}`); + * console.log(`The image url is: ${metada.url}`); + * console.log(`The image support url is: ${metada.supportUrl}`); + * console.log(`The image logo is: ${metada.logo}`); + * }); + */ +exports.getImageMetadata = (file) => { + return exports.getFromFilePath(file).then((image) => { + return _.omitBy(image, _.isObject); + }); +}; + +/** + * @summary Supported file types + * @type {String[]} + * @public + * + * @example + * const imageStream = require('./lib/image-stream'); + * + * imageStream.supportedFileTypes.forEach((fileType) => { + * console.log('Supported file type: ' + fileType.extension); + * }); + */ +exports.supportedFileTypes = supportedFileTypes; diff --git a/lib/image-stream/supported.js b/lib/image-stream/supported.js new file mode 100644 index 00000000..18d66c36 --- /dev/null +++ b/lib/image-stream/supported.js @@ -0,0 +1,60 @@ +/* + * Copyright 2016 resin.io + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +module.exports = [ + { + extension: 'zip', + type: 'archive' + }, + { + extension: 'etch', + type: 'archive' + }, + { + extension: 'gz', + type: 'compressed' + }, + { + extension: 'bz2', + type: 'compressed' + }, + { + extension: 'xz', + type: 'compressed' + }, + { + extension: 'img', + type: 'image' + }, + { + extension: 'iso', + type: 'image' + }, + { + extension: 'dsk', + type: 'image' + }, + { + extension: 'hddimg', + type: 'image' + }, + { + extension: 'raw', + type: 'image' + } +]; diff --git a/lib/image-stream/utils.js b/lib/image-stream/utils.js new file mode 100644 index 00000000..fef1d62d --- /dev/null +++ b/lib/image-stream/utils.js @@ -0,0 +1,41 @@ +/* + * Copyright 2016 resin.io + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const _ = require('lodash'); +const readChunk = require('read-chunk'); +const archiveType = require('archive-type'); + +/** + * @summary Get archive mime type + * @function + * @public + * + * @param {String} file - file path + * @returns {String} mime type + * + * @example + * utils.getArchiveMimeType('path/to/raspberrypi.img.gz'); + */ +exports.getArchiveMimeType = (file) => { + + // archive-type only needs the first 261 bytes + // See https://github.com/kevva/archive-type + const chunk = readChunk.sync(file, 0, 261); + + return _.get(archiveType(chunk), 'mime', 'application/octet-stream'); +}; diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index bf1e5a98..7de6e48a 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -309,18 +309,6 @@ "from": "esprima@>=2.6.0 <3.0.0", "resolved": "https://registry.npmjs.org/esprima/-/esprima-2.7.2.tgz" }, - "etcher-image-stream": { - "version": "5.1.0", - "from": "etcher-image-stream@5.1.0", - "resolved": "https://registry.npmjs.org/etcher-image-stream/-/etcher-image-stream-5.1.0.tgz", - "dependencies": { - "yauzl": { - "version": "2.6.0", - "from": "yauzl@>=2.6.0 <3.0.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.6.0.tgz" - } - } - }, "etcher-image-write": { "version": "9.0.0", "from": "etcher-image-write@9.0.0", @@ -396,9 +384,9 @@ "resolved": "https://registry.npmjs.org/file-tail/-/file-tail-0.3.0.tgz" }, "file-type": { - "version": "3.8.0", + "version": "3.9.0", "from": "file-type@>=3.1.0 <4.0.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-3.8.0.tgz" + "resolved": "https://registry.npmjs.org/file-type/-/file-type-3.9.0.tgz" }, "find-up": { "version": "1.1.2", @@ -611,11 +599,6 @@ "from": "commander@>=2.9.0 <3.0.0", "resolved": "https://registry.npmjs.org/commander/-/commander-2.9.0.tgz" }, - "isarray": { - "version": "1.0.0", - "from": "isarray@>=1.0.0 <1.1.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz" - }, "node-pre-gyp": { "version": "0.6.29", "from": "node-pre-gyp@0.6.29", @@ -1217,11 +1200,6 @@ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.1.tgz" } } - }, - "readable-stream": { - "version": "2.1.5", - "from": "readable-stream@>=2.0.5 <3.0.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.1.5.tgz" } } }, @@ -1392,6 +1370,18 @@ "from": "read-pkg-up@>=1.0.1 <2.0.0", "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-1.0.1.tgz" }, + "readable-stream": { + "version": "2.2.2", + "from": "readable-stream@>=2.0.2 <3.0.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.2.2.tgz", + "dependencies": { + "isarray": { + "version": "1.0.0", + "from": "isarray@>=1.0.0 <1.1.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz" + } + } + }, "readline2": { "version": "1.0.1", "from": "readline2@>=1.0.1 <2.0.0", @@ -1412,11 +1402,6 @@ "from": "require-main-filename@>=1.0.1 <2.0.0", "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-1.0.1.tgz" }, - "require-uncached": { - "version": "1.0.2", - "from": "require-uncached@>=1.0.2 <2.0.0", - "resolved": "https://registry.npmjs.org/require-uncached/-/require-uncached-1.0.2.tgz" - }, "resin-cli-form": { "version": "1.4.1", "from": "resin-cli-form@>=1.4.1 <2.0.0", @@ -1605,19 +1590,7 @@ "string-to-stream": { "version": "1.1.0", "from": "string-to-stream@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/string-to-stream/-/string-to-stream-1.1.0.tgz", - "dependencies": { - "isarray": { - "version": "1.0.0", - "from": "isarray@~1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz" - }, - "readable-stream": { - "version": "2.1.5", - "from": "readable-stream@^2.1.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.1.5.tgz" - } - } + "resolved": "https://registry.npmjs.org/string-to-stream/-/string-to-stream-1.1.0.tgz" }, "string-width": { "version": "1.0.1", @@ -1787,6 +1760,11 @@ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-2.1.1.tgz" } } + }, + "yauzl": { + "version": "2.6.0", + "from": "yauzl@2.6.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.6.0.tgz" } } } diff --git a/package.json b/package.json index c93655f4..48935fda 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "url": "git@github.com:resin-io/etcher.git" }, "scripts": { - "test": "npm run lint && electron-mocha --recursive --renderer tests/gui -R spec && electron-mocha --recursive tests/cli tests/shared tests/child-writer -R spec", + "test": "npm run lint && electron-mocha --recursive --renderer tests/gui -R spec && electron-mocha --recursive tests/cli tests/shared tests/child-writer tests/image-stream -R spec", "sass": "node-sass ./lib/gui/scss/main.scss > ./lib/gui/css/main.css", "jslint": "eslint lib tests scripts bin versionist.conf.js", "scsslint": "scss-lint lib/gui/scss", @@ -67,31 +67,38 @@ "angular-seconds-to-date": "^1.0.0", "angular-ui-bootstrap": "^1.3.2", "angular-ui-router": "^0.2.18", + "archive-type": "^3.2.0", "bluebird": "^3.0.5", "bootstrap-sass": "^3.3.5", "chalk": "^1.1.3", "drivelist": "^5.0.6", "electron-is-running-in-asar": "^1.0.0", - "etcher-image-stream": "^5.1.0", "etcher-image-write": "^9.0.0", "etcher-latest-version": "^1.0.0", "file-tail": "^0.3.0", "flexboxgrid": "^6.3.0", + "gzip-uncompressed-size": "^1.0.0", "immutable": "^3.8.1", "is-elevated": "^1.0.0", "lodash": "^4.5.1", + "lzma-native": "^1.5.2", "node-ipc": "^8.9.2", + "node-stream-zip": "^1.3.4", + "read-chunk": "^2.0.0", "redux": "^3.5.2", "redux-localstorage": "^0.4.1", "resin-cli-form": "^1.4.1", "resin-cli-visuals": "^1.2.8", + "rindle": "^1.3.0", "rx": "^4.1.0", "semver": "^5.1.0", "sudo-prompt": "^6.1.0", "tail": "^1.1.0", "trackjs": "^2.1.16", + "unbzip2-stream": "^1.0.10", "username": "^2.1.0", - "yargs": "^4.6.0" + "yargs": "^4.6.0", + "yauzl": "^2.6.0" }, "devDependencies": { "angular-mocks": "^1.4.7", @@ -102,9 +109,11 @@ "electron-packager": "^7.0.1", "electron-prebuilt": "1.4.4", "eslint": "^2.13.1", + "file-exists": "^1.0.0", "jsonfile": "^2.3.1", "mochainon": "^1.0.0", "node-sass": "^3.8.0", + "tmp": "0.0.31", "versionist": "^2.1.0" }, "config": { diff --git a/tests/image-stream/archive-hooks/zip.spec.js b/tests/image-stream/archive-hooks/zip.spec.js new file mode 100644 index 00000000..f6242d1f --- /dev/null +++ b/tests/image-stream/archive-hooks/zip.spec.js @@ -0,0 +1,149 @@ +/* + * Copyright 2016 resin.io + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const m = require('mochainon'); +const path = require('path'); +const rindle = require('rindle'); +const zipHooks = require('../../../lib/image-stream/archive-hooks/zip'); +const ZIP_PATH = path.join(__dirname, '..', 'data', 'zip'); + +describe('ImageStream: Archive hooks: ZIP', function() { + + this.timeout(20000); + + describe('.getEntries()', function() { + + describe('given an empty zip', function() { + + beforeEach(function() { + this.zip = path.join(ZIP_PATH, 'zip-directory-empty.zip'); + }); + + it('should become an empty array', function(done) { + zipHooks.getEntries(this.zip).then((entries) => { + m.chai.expect(entries).to.deep.equal([]); + }).asCallback(done); + }); + + }); + + describe('given a zip with multiple files in it', function() { + + beforeEach(function() { + this.zip = path.join(ZIP_PATH, 'zip-directory-multiple-images.zip'); + }); + + it('should become all entries', function(done) { + zipHooks.getEntries(this.zip).then((entries) => { + m.chai.expect(entries).to.deep.equal([ + { + name: 'multiple-images/edison-config.img', + size: 16777216 + }, + { + name: 'multiple-images/raspberrypi.img', + size: 33554432 + } + ]); + }).asCallback(done); + }); + + }); + + describe('given a zip with nested files in it', function() { + + beforeEach(function() { + this.zip = path.join(ZIP_PATH, 'zip-directory-nested-misc.zip'); + }); + + it('should become all entries', function(done) { + zipHooks.getEntries(this.zip).then((entries) => { + m.chai.expect(entries).to.deep.equal([ + { + name: 'zip-directory-nested-misc/foo', + size: 4 + }, + { + name: 'zip-directory-nested-misc/hello/there/bar', + size: 4 + } + ]); + }).asCallback(done); + }); + + }); + + }); + + describe('.extractFile()', function() { + + beforeEach(function() { + this.zip = path.join(ZIP_PATH, 'zip-directory-nested-misc.zip'); + }); + + it('should be able to extract a top-level file', function(done) { + const fileName = 'zip-directory-nested-misc/foo'; + zipHooks.getEntries(this.zip).then((entries) => { + return zipHooks.extractFile(this.zip, entries, fileName); + }).then((stream) => { + rindle.extract(stream, function(error, data) { + m.chai.expect(error).to.not.exist; + m.chai.expect(data).to.equal('foo\n'); + done(); + }); + }); + }); + + it('should be able to extract a nested file', function(done) { + const fileName = 'zip-directory-nested-misc/hello/there/bar'; + zipHooks.getEntries(this.zip).then((entries) => { + return zipHooks.extractFile(this.zip, entries, fileName); + }).then((stream) => { + rindle.extract(stream, function(error, data) { + m.chai.expect(error).to.not.exist; + m.chai.expect(data).to.equal('bar\n'); + done(); + }); + }); + }); + + it('should throw if the entry does not exist', function(done) { + const fileName = 'zip-directory-nested-misc/xxxxxxxxxxxxxxxx'; + zipHooks.getEntries(this.zip).then((entries) => { + return zipHooks.extractFile(this.zip, entries, fileName); + }).catch((error) => { + m.chai.expect(error).to.be.an.instanceof(Error); + m.chai.expect(error.message).to.equal(`Invalid entry: ${fileName}`); + done(); + }); + }); + + it('should throw if the entry is a directory', function(done) { + const fileName = 'zip-directory-nested-misc/hello'; + zipHooks.getEntries(this.zip).then((entries) => { + return zipHooks.extractFile(this.zip, entries, fileName); + }).catch((error) => { + m.chai.expect(error).to.be.an.instanceof(Error); + m.chai.expect(error.message).to.equal(`Invalid entry: ${fileName}`); + done(); + }); + }); + + }); + +}); diff --git a/tests/image-stream/bz2.spec.js b/tests/image-stream/bz2.spec.js new file mode 100644 index 00000000..34da9f7d --- /dev/null +++ b/tests/image-stream/bz2.spec.js @@ -0,0 +1,58 @@ +/* + * Copyright 2016 resin.io + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const m = require('mochainon'); +const fs = require('fs'); +const path = require('path'); +const DATA_PATH = path.join(__dirname, 'data'); +const IMAGES_PATH = path.join(DATA_PATH, 'images'); +const BZ2_PATH = path.join(DATA_PATH, 'bz2'); +const imageStream = require('../../lib/image-stream/index'); +const tester = require('./tester'); + +describe('ImageStream: BZ2', function() { + + this.timeout(20000); + + describe('.getFromFilePath()', function() { + + describe('given a bz2 image', function() { + tester.extractFromFilePath( + path.join(BZ2_PATH, 'raspberrypi.img.bz2'), + path.join(IMAGES_PATH, 'raspberrypi.img')); + }); + + }); + + describe('.getImageMetadata()', function() { + + it('should return the correct metadata', function(done) { + const image = path.join(BZ2_PATH, 'raspberrypi.img.bz2'); + const expectedSize = fs.statSync(image).size; + + imageStream.getImageMetadata(image).then((metadata) => { + m.chai.expect(metadata).to.deep.equal({ + size: expectedSize + }); + done(); + }); + }); + + }); + +}); diff --git a/tests/image-stream/data/bz2/raspberrypi.img.bz2 b/tests/image-stream/data/bz2/raspberrypi.img.bz2 new file mode 100644 index 00000000..1b419230 Binary files /dev/null and b/tests/image-stream/data/bz2/raspberrypi.img.bz2 differ diff --git a/tests/image-stream/data/gz/raspberrypi.img.gz b/tests/image-stream/data/gz/raspberrypi.img.gz new file mode 100644 index 00000000..7f9d26f3 Binary files /dev/null and b/tests/image-stream/data/gz/raspberrypi.img.gz differ diff --git a/tests/image-stream/data/images/raspberrypi.img b/tests/image-stream/data/images/raspberrypi.img new file mode 100644 index 00000000..1cf96222 Binary files /dev/null and b/tests/image-stream/data/images/raspberrypi.img differ diff --git a/tests/image-stream/data/metadata/zip/rpi-invalid-manifest.zip b/tests/image-stream/data/metadata/zip/rpi-invalid-manifest.zip new file mode 100644 index 00000000..21a0bbc8 Binary files /dev/null and b/tests/image-stream/data/metadata/zip/rpi-invalid-manifest.zip differ diff --git a/tests/image-stream/data/metadata/zip/rpi-with-bmap.zip b/tests/image-stream/data/metadata/zip/rpi-with-bmap.zip new file mode 100644 index 00000000..14cc086b Binary files /dev/null and b/tests/image-stream/data/metadata/zip/rpi-with-bmap.zip differ diff --git a/tests/image-stream/data/metadata/zip/rpi-with-instructions.zip b/tests/image-stream/data/metadata/zip/rpi-with-instructions.zip new file mode 100644 index 00000000..19c75692 Binary files /dev/null and b/tests/image-stream/data/metadata/zip/rpi-with-instructions.zip differ diff --git a/tests/image-stream/data/metadata/zip/rpi-with-logo.zip b/tests/image-stream/data/metadata/zip/rpi-with-logo.zip new file mode 100644 index 00000000..5b0158f6 Binary files /dev/null and b/tests/image-stream/data/metadata/zip/rpi-with-logo.zip differ diff --git a/tests/image-stream/data/metadata/zip/rpi-with-manifest.zip b/tests/image-stream/data/metadata/zip/rpi-with-manifest.zip new file mode 100644 index 00000000..15289b19 Binary files /dev/null and b/tests/image-stream/data/metadata/zip/rpi-with-manifest.zip differ diff --git a/tests/image-stream/data/xz/raspberrypi.img.xz b/tests/image-stream/data/xz/raspberrypi.img.xz new file mode 100644 index 00000000..5e33ece2 Binary files /dev/null and b/tests/image-stream/data/xz/raspberrypi.img.xz differ diff --git a/tests/image-stream/data/zip/zip-directory-empty.zip b/tests/image-stream/data/zip/zip-directory-empty.zip new file mode 100644 index 00000000..944763ab Binary files /dev/null and b/tests/image-stream/data/zip/zip-directory-empty.zip differ diff --git a/tests/image-stream/data/zip/zip-directory-multiple-images.zip b/tests/image-stream/data/zip/zip-directory-multiple-images.zip new file mode 100644 index 00000000..f71a56e6 Binary files /dev/null and b/tests/image-stream/data/zip/zip-directory-multiple-images.zip differ diff --git a/tests/image-stream/data/zip/zip-directory-nested-misc.zip b/tests/image-stream/data/zip/zip-directory-nested-misc.zip new file mode 100644 index 00000000..126e2b47 Binary files /dev/null and b/tests/image-stream/data/zip/zip-directory-nested-misc.zip differ diff --git a/tests/image-stream/data/zip/zip-directory-no-image-only-misc.zip b/tests/image-stream/data/zip/zip-directory-no-image-only-misc.zip new file mode 100644 index 00000000..f796dc1f Binary files /dev/null and b/tests/image-stream/data/zip/zip-directory-no-image-only-misc.zip differ diff --git a/tests/image-stream/data/zip/zip-directory-rpi-and-misc.zip b/tests/image-stream/data/zip/zip-directory-rpi-and-misc.zip new file mode 100644 index 00000000..ea202cd6 Binary files /dev/null and b/tests/image-stream/data/zip/zip-directory-rpi-and-misc.zip differ diff --git a/tests/image-stream/data/zip/zip-directory-rpi-only.zip b/tests/image-stream/data/zip/zip-directory-rpi-only.zip new file mode 100644 index 00000000..50ed1539 Binary files /dev/null and b/tests/image-stream/data/zip/zip-directory-rpi-only.zip differ diff --git a/tests/image-stream/gz.spec.js b/tests/image-stream/gz.spec.js new file mode 100644 index 00000000..46907f5a --- /dev/null +++ b/tests/image-stream/gz.spec.js @@ -0,0 +1,60 @@ +/* + * Copyright 2016 resin.io + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const m = require('mochainon'); +const fs = require('fs'); +const path = require('path'); +const DATA_PATH = path.join(__dirname, 'data'); +const IMAGES_PATH = path.join(DATA_PATH, 'images'); +const GZ_PATH = path.join(DATA_PATH, 'gz'); +const imageStream = require('../../lib/image-stream/index'); +const tester = require('./tester'); + +describe('ImageStream: GZ', function() { + + this.timeout(20000); + + describe('.getFromFilePath()', function() { + + describe('given a gz image', function() { + tester.extractFromFilePath( + path.join(GZ_PATH, 'raspberrypi.img.gz'), + path.join(IMAGES_PATH, 'raspberrypi.img')); + }); + + }); + + describe('.getImageMetadata()', function() { + + it('should return the correct metadata', function(done) { + const image = path.join(GZ_PATH, 'raspberrypi.img.gz'); + const expectedSize = fs.statSync(path.join(IMAGES_PATH, 'raspberrypi.img')).size; + const size = fs.statSync(path.join(GZ_PATH, 'raspberrypi.img.gz')).size; + + imageStream.getImageMetadata(image).then((metadata) => { + m.chai.expect(metadata).to.deep.equal({ + estimatedUncompressedSize: expectedSize, + size: size + }); + done(); + }); + }); + + }); + +}); diff --git a/tests/image-stream/img.spec.js b/tests/image-stream/img.spec.js new file mode 100644 index 00000000..666788d1 --- /dev/null +++ b/tests/image-stream/img.spec.js @@ -0,0 +1,57 @@ +/* + * Copyright 2016 resin.io + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const m = require('mochainon'); +const fs = require('fs'); +const path = require('path'); +const DATA_PATH = path.join(__dirname, 'data'); +const IMAGES_PATH = path.join(DATA_PATH, 'images'); +const imageStream = require('../../lib/image-stream/index'); +const tester = require('./tester'); + +describe('ImageStream: IMG', function() { + + this.timeout(20000); + + describe('.getFromFilePath()', function() { + + describe('given an img image', function() { + tester.extractFromFilePath( + path.join(IMAGES_PATH, 'raspberrypi.img'), + path.join(IMAGES_PATH, 'raspberrypi.img')); + }); + + }); + + describe('.getImageMetadata()', function() { + + it('should return the correct metadata', function(done) { + const image = path.join(IMAGES_PATH, 'raspberrypi.img'); + const expectedSize = fs.statSync(image).size; + + imageStream.getImageMetadata(image).then((metadata) => { + m.chai.expect(metadata).to.deep.equal({ + size: expectedSize + }); + done(); + }); + }); + + }); + +}); diff --git a/tests/image-stream/index.spec.js b/tests/image-stream/index.spec.js new file mode 100644 index 00000000..3f5f02ab --- /dev/null +++ b/tests/image-stream/index.spec.js @@ -0,0 +1,55 @@ +/* + * Copyright 2016 resin.io + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const m = require('mochainon'); +const _ = require('lodash'); +const imageStream = require('../../lib/image-stream/index'); + +describe('ImageStream', function() { + + describe('.supportedFileTypes', function() { + + it('should be an array', function() { + m.chai.expect(_.isArray(imageStream.supportedFileTypes)).to.be.true; + }); + + it('should not be empty', function() { + m.chai.expect(_.isEmpty(imageStream.supportedFileTypes)).to.be.false; + }); + + it('should contain only strings', function() { + m.chai.expect(_.every(_.map(imageStream.supportedFileTypes, function(fileType) { + return _.isString(fileType.extension) && _.isString(fileType.type); + }))).to.be.true; + }); + + it('should not contain empty strings', function() { + m.chai.expect(_.every(_.map(imageStream.supportedFileTypes, function(fileType) { + return !_.isEmpty(fileType.extension) && !_.isEmpty(fileType.type); + }))).to.be.true; + }); + + it('should not contain a leading period in any file type extension', function() { + m.chai.expect(_.every(_.map(imageStream.supportedFileTypes, function(fileType) { + return _.first(fileType.extension) !== '.'; + }))).to.be.true; + }); + + }); + +}); diff --git a/tests/image-stream/metadata/zip.spec.js b/tests/image-stream/metadata/zip.spec.js new file mode 100644 index 00000000..2a07ae0c --- /dev/null +++ b/tests/image-stream/metadata/zip.spec.js @@ -0,0 +1,171 @@ +/* + * Copyright 2016 resin.io + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const m = require('mochainon'); +const path = require('path'); +const DATA_PATH = path.join(__dirname, '..', 'data'); +const IMAGES_PATH = path.join(DATA_PATH, 'images'); +const ZIP_PATH = path.join(DATA_PATH, 'metadata', 'zip'); +const tester = require('../tester'); +const imageStream = require('../../../lib/image-stream/index'); + +const testMetadataProperty = (archivePath, propertyName, expectedValue) => { + return imageStream.getFromFilePath(archivePath).then((image) => { + m.chai.expect(image[propertyName]).to.deep.equal(expectedValue); + + return imageStream.getImageMetadata(archivePath).then((metadata) => { + m.chai.expect(metadata[propertyName]).to.deep.equal(expectedValue); + }); + }); +}; + +describe('ImageStream: Metadata ZIP', function() { + + this.timeout(10000); + + describe('given an archive with an invalid `manifest.json`', function() { + + tester.expectError( + path.join(ZIP_PATH, 'rpi-invalid-manifest.zip'), + 'Invalid archive manifest.json'); + + describe('.getImageMetadata()', function() { + + it('should be rejected with an error', function(done) { + const image = path.join(ZIP_PATH, 'rpi-invalid-manifest.zip'); + + imageStream.getImageMetadata(image).catch((error) => { + m.chai.expect(error).to.be.an.instanceof(Error); + m.chai.expect(error.message).to.equal('Invalid archive manifest.json'); + done(); + }); + }); + + }); + + }); + + describe('given an archive with a `manifest.json`', function() { + + const archive = path.join(ZIP_PATH, 'rpi-with-manifest.zip'); + + tester.extractFromFilePath( + archive, + path.join(IMAGES_PATH, 'raspberrypi.img')); + + it('should read the manifest name property', function(done) { + testMetadataProperty(archive, 'name', 'Raspberry Pi').asCallback(done); + }); + + it('should read the manifest version property', function(done) { + testMetadataProperty(archive, 'version', '1.0.0').asCallback(done); + }); + + it('should read the manifest url property', function(done) { + testMetadataProperty(archive, 'url', 'https://www.raspberrypi.org').asCallback(done); + }); + + it('should read the manifest supportUrl property', function(done) { + const expectedValue = 'https://www.raspberrypi.org/forums/'; + testMetadataProperty(archive, 'supportUrl', expectedValue).asCallback(done); + }); + + it('should read the manifest releaseNotesUrl property', function(done) { + const expectedValue = 'http://downloads.raspberrypi.org/raspbian/release_notes.txt'; + testMetadataProperty(archive, 'releaseNotesUrl', expectedValue).asCallback(done); + }); + + it('should read the manifest checksumType property', function(done) { + testMetadataProperty(archive, 'checksumType', 'md5').asCallback(done); + }); + + it('should read the manifest checksum property', function(done) { + testMetadataProperty(archive, 'checksum', 'add060b285d512f56c175b76b7ef1bee').asCallback(done); + }); + + it('should read the manifest bytesToZeroOutFromTheBeginning property', function(done) { + testMetadataProperty(archive, 'bytesToZeroOutFromTheBeginning', 512).asCallback(done); + }); + + it('should read the manifest recommendedDriveSize property', function(done) { + testMetadataProperty(archive, 'recommendedDriveSize', 4294967296).asCallback(done); + }); + + }); + + describe('given an archive with a `logo.svg`', function() { + + const archive = path.join(ZIP_PATH, 'rpi-with-logo.zip'); + + const logo = [ + '', + ' Hello World', + '', + '' + ].join('\n'); + + it('should read the logo contents', function(done) { + testMetadataProperty(archive, 'logo', logo).asCallback(done); + }); + + }); + + describe('given an archive with a bmap file', function() { + + const archive = path.join(ZIP_PATH, 'rpi-with-bmap.zip'); + + const bmap = [ + '', + '', + ' 36864 ', + ' 4096 ', + ' 9 ', + ' 4 ', + ' d90f372215cbbef8801caca7b1dd7e587b2142cc ', + ' ', + ' 0-1 ', + ' 7-8 ', + ' ', + '', + '' + ].join('\n'); + + it('should read the bmap contents', function(done) { + testMetadataProperty(archive, 'bmap', bmap).asCallback(done); + }); + + }); + + describe('given an archive with instructions', function() { + + const archive = path.join(ZIP_PATH, 'rpi-with-instructions.zip'); + + const instructions = [ + '# Raspberry Pi Next Steps', + '', + 'Lorem ipsum dolor sit amet.', + '' + ].join('\n'); + + it('should read the instruction contents', function(done) { + testMetadataProperty(archive, 'instructions', instructions).asCallback(done); + }); + + }); + +}); diff --git a/tests/image-stream/tester.js b/tests/image-stream/tester.js new file mode 100644 index 00000000..16d04732 --- /dev/null +++ b/tests/image-stream/tester.js @@ -0,0 +1,82 @@ +/* + * Copyright 2016 resin.io + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const m = require('mochainon'); +const _ = require('lodash'); +const Bluebird = require('bluebird'); +const fileExists = require('file-exists'); +const fs = Bluebird.promisifyAll(require('fs')); +const tmp = require('tmp'); +const rindle = require('rindle'); +const imageStream = require('../../lib/image-stream/index'); + +const doFilesContainTheSameData = (file1, file2) => { + return Bluebird.props({ + file1: fs.readFileAsync(file1), + file2: fs.readFileAsync(file2) + }).then(function(data) { + return _.isEqual(data.file1, data.file2); + }); +}; + +const deleteIfExists = (file) => { + return Bluebird.try(function() { + if (fileExists(file)) { + return fs.unlinkAsync(file); + } + }); +}; + +exports.expectError = function(file, errorMessage) { + it('should be rejected with an error', function(done) { + imageStream.getFromFilePath(file).catch((error) => { + m.chai.expect(error).to.be.an.instanceof(Error); + m.chai.expect(error.message).to.equal(errorMessage); + m.chai.expect(error.description).to.be.a.string; + m.chai.expect(error.description.length > 0).to.be.true; + done(); + }); + }); +}; + +exports.extractFromFilePath = function(file, image) { + it('should be able to extract the image', function(done) { + const output = tmp.tmpNameSync(); + + imageStream.getFromFilePath(file).then(function(results) { + if (!_.some([ + results.size === fs.statSync(file).size, + results.size === fs.statSync(image).size + ])) { + throw new Error('Invalid size: ' + results.size); + } + + const stream = results.stream + .pipe(results.transform) + .pipe(fs.createWriteStream(output)); + + return rindle.wait(stream); + }).then(function() { + return doFilesContainTheSameData(image, output); + }).then(function(areEqual) { + m.chai.expect(areEqual).to.be.true; + }).finally(function() { + return deleteIfExists(output); + }).nodeify(done); + }); +}; diff --git a/tests/image-stream/utils.spec.js b/tests/image-stream/utils.spec.js new file mode 100644 index 00000000..54a312cf --- /dev/null +++ b/tests/image-stream/utils.spec.js @@ -0,0 +1,55 @@ +/* + * Copyright 2016 resin.io + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const m = require('mochainon'); +const path = require('path'); +const DATA_PATH = path.join(__dirname, 'data'); +const utils = require('../../lib/image-stream/utils'); + +describe('ImageStream: Utils', function() { + + describe('.getArchiveMimeType()', function() { + + it('should return application/x-bzip2 for a bz2 archive', function() { + const file = path.join(DATA_PATH, 'bz2', 'raspberrypi.img.bz2'); + m.chai.expect(utils.getArchiveMimeType(file)).to.equal('application/x-bzip2'); + }); + + it('should return application/x-xz for a xz archive', function() { + const file = path.join(DATA_PATH, 'xz', 'raspberrypi.img.xz'); + m.chai.expect(utils.getArchiveMimeType(file)).to.equal('application/x-xz'); + }); + + it('should return application/gzip for a gz archive', function() { + const file = path.join(DATA_PATH, 'gz', 'raspberrypi.img.gz'); + m.chai.expect(utils.getArchiveMimeType(file)).to.equal('application/gzip'); + }); + + it('should return application/zip for a zip archive', function() { + const file = path.join(DATA_PATH, 'zip', 'zip-directory-rpi-only.zip'); + m.chai.expect(utils.getArchiveMimeType(file)).to.equal('application/zip'); + }); + + it('should return application/octet-stream for an uncompress image', function() { + const file = path.join(DATA_PATH, 'images', 'raspberrypi.img'); + m.chai.expect(utils.getArchiveMimeType(file)).to.equal('application/octet-stream'); + }); + + }); + +}); diff --git a/tests/image-stream/xz.spec.js b/tests/image-stream/xz.spec.js new file mode 100644 index 00000000..28dda9f1 --- /dev/null +++ b/tests/image-stream/xz.spec.js @@ -0,0 +1,58 @@ +/* + * Copyright 2016 resin.io + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const m = require('mochainon'); +const fs = require('fs'); +const path = require('path'); +const DATA_PATH = path.join(__dirname, 'data'); +const IMAGES_PATH = path.join(DATA_PATH, 'images'); +const XZ_PATH = path.join(DATA_PATH, 'xz'); +const imageStream = require('../../lib/image-stream/index'); +const tester = require('./tester'); + +describe('ImageStream: XZ', function() { + + this.timeout(20000); + + describe('.getFromFilePath()', function() { + + describe('given a xz image', function() { + tester.extractFromFilePath( + path.join(XZ_PATH, 'raspberrypi.img.xz'), + path.join(IMAGES_PATH, 'raspberrypi.img')); + }); + + }); + + describe('.getImageMetadata()', function() { + + it('should return the correct metadata', function(done) { + const image = path.join(XZ_PATH, 'raspberrypi.img.xz'); + const expectedSize = fs.statSync(path.join(IMAGES_PATH, 'raspberrypi.img')).size; + + imageStream.getImageMetadata(image).then((metadata) => { + m.chai.expect(metadata).to.deep.equal({ + size: expectedSize + }); + done(); + }); + }); + + }); + +}); diff --git a/tests/image-stream/zip.spec.js b/tests/image-stream/zip.spec.js new file mode 100644 index 00000000..b380aab7 --- /dev/null +++ b/tests/image-stream/zip.spec.js @@ -0,0 +1,82 @@ +/* + * Copyright 2016 resin.io + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const m = require('mochainon'); +const fs = require('fs'); +const path = require('path'); +const DATA_PATH = path.join(__dirname, 'data'); +const IMAGES_PATH = path.join(DATA_PATH, 'images'); +const ZIP_PATH = path.join(DATA_PATH, 'zip'); +const imageStream = require('../../lib/image-stream/index'); +const tester = require('./tester'); + +describe('ImageStream: ZIP', function() { + + this.timeout(20000); + + describe('.getFromFilePath()', function() { + + describe('given an empty zip directory', function() { + tester.expectError( + path.join(ZIP_PATH, 'zip-directory-empty.zip'), + 'Invalid archive image'); + }); + + describe('given a zip directory containing only misc files', function() { + tester.expectError( + path.join(ZIP_PATH, 'zip-directory-no-image-only-misc.zip'), + 'Invalid archive image'); + }); + + describe('given a zip directory containing multiple images', function() { + tester.expectError( + path.join(ZIP_PATH, 'zip-directory-multiple-images.zip'), + 'Invalid archive image'); + }); + + describe('given a zip directory containing only an image', function() { + tester.extractFromFilePath( + path.join(ZIP_PATH, 'zip-directory-rpi-only.zip'), + path.join(IMAGES_PATH, 'raspberrypi.img')); + }); + + describe('given a zip directory containing an image and other misc files', function() { + tester.extractFromFilePath( + path.join(ZIP_PATH, 'zip-directory-rpi-and-misc.zip'), + path.join(IMAGES_PATH, 'raspberrypi.img')); + }); + + }); + + describe('.getImageMetadata()', function() { + + it('should return the correct metadata', function(done) { + const image = path.join(ZIP_PATH, 'zip-directory-rpi-only.zip'); + const expectedSize = fs.statSync(path.join(IMAGES_PATH, 'raspberrypi.img')).size; + + imageStream.getImageMetadata(image).then((metadata) => { + m.chai.expect(metadata).to.deep.equal({ + size: expectedSize + }); + done(); + }); + }); + + }); + +});