diff --git a/lib/gui/models/store.js b/lib/gui/models/store.js index 8c64216f..c13058f6 100644 --- a/lib/gui/models/store.js +++ b/lib/gui/models/store.js @@ -23,6 +23,7 @@ const persistState = require('redux-localstorage'); const uuidV4 = require('uuid/v4'); const constraints = require('../../shared/drive-constraints'); const errors = require('../../shared/errors'); +const fileExtensions = require('../../shared/file-extensions'); /** * @summary Application default state @@ -331,6 +332,32 @@ const storeReducer = (state = DEFAULT_STATE, action) => { }); } + if (!action.data.extension) { + throw errors.createError({ + title: 'Missing image extension' + }); + } + + if (!_.isString(action.data.extension)) { + throw errors.createError({ + title: `Invalid image extension: ${action.data.extension}` + }); + } + + if (fileExtensions.getLastFileExtension(action.data.path) !== action.data.extension) { + if (!action.data.archiveExtension) { + throw errors.createError({ + title: 'Missing image archive extension' + }); + } + + if (!_.isString(action.data.archiveExtension)) { + throw errors.createError({ + title: `Invalid image archive extension: ${action.data.archiveExtension}` + }); + } + } + if (!action.data.size) { throw errors.createError({ title: 'Missing image size' diff --git a/lib/image-stream/archive.js b/lib/image-stream/archive.js index 207e3568..b8e6dfde 100644 --- a/lib/image-stream/archive.js +++ b/lib/image-stream/archive.js @@ -16,13 +16,13 @@ 'use strict'; -const path = require('path'); const Bluebird = require('bluebird'); const _ = require('lodash'); const PassThroughStream = require('stream').PassThrough; const supportedFileTypes = require('./supported'); const utils = require('./utils'); const errors = require('../shared/errors'); +const fileExtensions = require('../shared/file-extensions'); /** * @summary Archive metadata base path @@ -172,8 +172,7 @@ exports.extractImage = (archive, hooks) => { return hooks.getEntries(archive).then((entries) => { const imageEntries = _.filter(entries, (entry) => { - const extension = _.toLower(_.replace(path.extname(entry.name), '.', '')); - return _.includes(IMAGE_EXTENSIONS, extension); + return _.includes(IMAGE_EXTENSIONS, fileExtensions.getLastFileExtension(entry.name)); }); const VALID_NUMBER_OF_IMAGE_ENTRIES = 1; @@ -205,6 +204,9 @@ exports.extractImage = (archive, hooks) => { } }; + results.metadata.extension = fileExtensions.getLastFileExtension(imageEntry.name); + results.metadata.archiveExtension = fileExtensions.getLastFileExtension(archive); + return results.metadata; }); }); diff --git a/lib/image-stream/handlers.js b/lib/image-stream/handlers.js index 16353c4f..1f209bae 100644 --- a/lib/image-stream/handlers.js +++ b/lib/image-stream/handlers.js @@ -26,6 +26,7 @@ const gzip = require('./gzip'); const udif = Bluebird.promisifyAll(require('udif')); const archive = require('./archive'); const zipArchiveHooks = require('./archive-hooks/zip'); +const fileExtensions = require('../shared/file-extensions'); /** * @summary Image handlers @@ -50,6 +51,8 @@ module.exports = { 'application/x-bzip2': (file, options) => { return Bluebird.props({ path: file, + archiveExtension: fileExtensions.getLastFileExtension(file), + extension: fileExtensions.getPenultimateFileExtension(file), stream: fs.createReadStream(file), size: { original: options.size, @@ -79,6 +82,8 @@ module.exports = { return gzip.getUncompressedSize(file).then((uncompressedSize) => { return Bluebird.props({ path: file, + archiveExtension: fileExtensions.getLastFileExtension(file), + extension: fileExtensions.getPenultimateFileExtension(file), stream: fs.createReadStream(file), size: { original: options.size, @@ -113,6 +118,8 @@ module.exports = { }).then((metadata) => { return { path: file, + archiveExtension: fileExtensions.getLastFileExtension(file), + extension: fileExtensions.getPenultimateFileExtension(file), stream: fs.createReadStream(file), size: { original: options.size, @@ -143,6 +150,7 @@ module.exports = { return udif.getUncompressedSizeAsync(file).then((size) => { return { path: file, + extension: fileExtensions.getLastFileExtension(file), stream: udif.createReadStream(file), size: { original: options.size, @@ -186,6 +194,7 @@ module.exports = { 'application/octet-stream': (file, options) => { return Bluebird.props({ path: file, + extension: fileExtensions.getLastFileExtension(file), stream: fs.createReadStream(file), size: { original: options.size, diff --git a/lib/shared/file-extensions.js b/lib/shared/file-extensions.js new file mode 100644 index 00000000..0b3b8d29 --- /dev/null +++ b/lib/shared/file-extensions.js @@ -0,0 +1,74 @@ +/* + * Copyright 2017 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'); + +/** + * @summary Get the extensions of a file + * @function + * @public + * + * @param {String} filePath - file path + * @returns {String[]} extensions + * + * @example + * const extensions = fileExtensions.getFileExtensions('path/to/foo.img.gz'); + * console.log(extensions); + * > [ 'img', 'gz' ] + */ +exports.getFileExtensions = (filePath) => { + return _.chain(filePath) + .split('.') + .tail() + .map(_.toLower) + .value(); +}; + +/** + * @summary Get the last file extension + * @function + * @public + * + * @param {String} filePath - file path + * @returns {(String|Undefined)} last extension + * + * @example + * const extension = fileExtensions.getLastFileExtension('path/to/foo.img.gz'); + * console.log(extension); + * > [ 'gz' ] + */ +exports.getLastFileExtension = (filePath) => { + return _.last(exports.getFileExtensions(filePath)); +}; + +/** + * @summary Get the penultimate file extension + * @function + * @public + * + * @param {String} filePath - file path + * @returns {(String|Undefined)} penultimate extension + * + * @example + * const extension = fileExtensions.getLastFileExtension('path/to/foo.img.gz'); + * console.log(extension); + * > [ 'img' ] + */ +exports.getPenultimateFileExtension = (filePath) => { + return _.last(_.initial(exports.getFileExtensions(filePath))); +}; diff --git a/lib/shared/supported-formats.js b/lib/shared/supported-formats.js index bb18d0a9..3da8a641 100644 --- a/lib/shared/supported-formats.js +++ b/lib/shared/supported-formats.js @@ -19,6 +19,7 @@ const _ = require('lodash'); const path = require('path'); const imageStream = require('../image-stream'); +const fileExtensions = require('./file-extensions'); /** * @summary Build an extension list getter from a type @@ -106,20 +107,20 @@ exports.getAllExtensions = () => { * } */ exports.isSupportedImage = (imagePath) => { - const extension = _.toLower(_.replace(path.extname(imagePath), '.', '')); + const lastExtension = fileExtensions.getLastFileExtension(imagePath); + const penultimateExtension = fileExtensions.getPenultimateFileExtension(imagePath); if (_.some([ - _.includes(exports.getNonCompressedExtensions(), extension), - _.includes(exports.getArchiveExtensions(), extension) + _.includes(exports.getNonCompressedExtensions(), lastExtension), + _.includes(exports.getArchiveExtensions(), lastExtension) ])) { return true; } - if (!_.includes(exports.getCompressedExtensions(), extension)) { - return false; - } - - return exports.isSupportedImage(path.basename(imagePath, `.${extension}`)); + return _.every([ + _.includes(exports.getCompressedExtensions(), lastExtension), + _.includes(exports.getNonCompressedExtensions(), penultimateExtension) + ]); }; /** diff --git a/tests/gui/models/drives.spec.js b/tests/gui/models/drives.spec.js index 5fb4d7e4..c103a91f 100644 --- a/tests/gui/models/drives.spec.js +++ b/tests/gui/models/drives.spec.js @@ -118,6 +118,7 @@ describe('Browser: DrivesModel', function() { SelectionStateModel.removeDrive(); SelectionStateModel.setImage({ path: this.imagePath, + extension: 'img', size: { original: 999999999, final: { diff --git a/tests/gui/models/selection-state.spec.js b/tests/gui/models/selection-state.spec.js index 1a7d1a7b..e1256d05 100644 --- a/tests/gui/models/selection-state.spec.js +++ b/tests/gui/models/selection-state.spec.js @@ -221,6 +221,7 @@ describe('Browser: SelectionState', function() { beforeEach(function() { this.image = { path: 'foo.img', + extension: 'img', size: { original: 999999999, final: { @@ -342,6 +343,7 @@ describe('Browser: SelectionState', function() { it('should override the image', function() { SelectionStateModel.setImage({ path: 'bar.img', + extension: 'img', size: { original: 999999999, final: { @@ -381,6 +383,7 @@ describe('Browser: SelectionState', function() { it('should be able to set an image', function() { SelectionStateModel.setImage({ path: 'foo.img', + extension: 'img', size: { original: 999999999, final: { @@ -396,9 +399,28 @@ describe('Browser: SelectionState', function() { m.chai.expect(imageSize).to.equal(999999999); }); + it('should be able to set an image with an archive extension', function() { + SelectionStateModel.setImage({ + path: 'foo.zip', + extension: 'img', + archiveExtension: 'zip', + size: { + original: 999999999, + final: { + estimation: false, + value: 999999999 + } + } + }); + + const imagePath = SelectionStateModel.getImagePath(); + m.chai.expect(imagePath).to.equal('foo.zip'); + }); + it('should throw if no path', function() { m.chai.expect(function() { SelectionStateModel.setImage({ + extension: 'img', size: { original: 999999999, final: { @@ -414,6 +436,7 @@ describe('Browser: SelectionState', function() { m.chai.expect(function() { SelectionStateModel.setImage({ path: 123, + extension: 'img', size: { original: 999999999, final: { @@ -425,10 +448,75 @@ describe('Browser: SelectionState', function() { }).to.throw('Invalid image path: 123'); }); + it('should throw if no extension', function() { + m.chai.expect(function() { + SelectionStateModel.setImage({ + path: 'foo.img', + size: { + original: 999999999, + final: { + estimation: false, + value: 999999999 + } + } + }); + }).to.throw('Missing image extension'); + }); + + it('should throw if extension is not a string', function() { + m.chai.expect(function() { + SelectionStateModel.setImage({ + path: 'foo.img', + extension: 1, + size: { + original: 999999999, + final: { + estimation: false, + value: 999999999 + } + } + }); + }).to.throw('Invalid image extension: 1'); + }); + + it('should throw if the extension doesn\'t match the path and there is no archive extension', function() { + m.chai.expect(function() { + SelectionStateModel.setImage({ + path: 'foo.img', + extension: 'iso', + size: { + original: 999999999, + final: { + estimation: false, + value: 999999999 + } + } + }); + }).to.throw('Missing image archive extension'); + }); + + it('should throw if the extension doesn\'t match the path and the archive extension is not a string', function() { + m.chai.expect(function() { + SelectionStateModel.setImage({ + path: 'foo.img', + extension: 'iso', + archiveExtension: 1, + size: { + original: 999999999, + final: { + estimation: false, + value: 999999999 + } + } + }); + }).to.throw('Invalid image archive extension: 1'); + }); + it('should throw if no size', function() { m.chai.expect(function() { SelectionStateModel.setImage({ - path: 'foo.img' + path: 'foo.img', + extension: 'img' }); }).to.throw('Missing image size'); }); @@ -437,6 +525,7 @@ describe('Browser: SelectionState', function() { m.chai.expect(function() { SelectionStateModel.setImage({ path: 'foo.img', + extension: 'img', size: 999999999 }); }).to.throw('Invalid image size: 999999999'); @@ -446,6 +535,7 @@ describe('Browser: SelectionState', function() { m.chai.expect(function() { SelectionStateModel.setImage({ path: 'foo.img', + extension: 'img', size: { original: '999999999', final: { @@ -461,6 +551,7 @@ describe('Browser: SelectionState', function() { m.chai.expect(function() { SelectionStateModel.setImage({ path: 'foo.img', + extension: 'img', size: { original: 999999999.999, final: { @@ -476,6 +567,7 @@ describe('Browser: SelectionState', function() { m.chai.expect(function() { SelectionStateModel.setImage({ path: 'foo.img', + extension: 'img', size: { original: -1, final: { @@ -491,6 +583,7 @@ describe('Browser: SelectionState', function() { m.chai.expect(function() { SelectionStateModel.setImage({ path: 'foo.img', + extension: 'img', size: { original: 999999999, final: { @@ -506,6 +599,7 @@ describe('Browser: SelectionState', function() { m.chai.expect(function() { SelectionStateModel.setImage({ path: 'foo.img', + extension: 'img', size: { original: 999999999, final: { @@ -521,6 +615,7 @@ describe('Browser: SelectionState', function() { m.chai.expect(function() { SelectionStateModel.setImage({ path: 'foo.img', + extension: 'img', size: { original: 999999999, final: { @@ -536,6 +631,7 @@ describe('Browser: SelectionState', function() { m.chai.expect(function() { SelectionStateModel.setImage({ path: 'foo.img', + extension: 'img', size: { original: 999999999, final: { @@ -551,6 +647,7 @@ describe('Browser: SelectionState', function() { m.chai.expect(function() { SelectionStateModel.setImage({ path: 'foo.img', + extension: 'img', size: { original: 999999999, final: { @@ -567,6 +664,7 @@ describe('Browser: SelectionState', function() { m.chai.expect(function() { SelectionStateModel.setImage({ path: 'foo.img', + extension: 'img', size: { original: 999999999, final: { @@ -583,6 +681,7 @@ describe('Browser: SelectionState', function() { m.chai.expect(function() { SelectionStateModel.setImage({ path: 'foo.img', + extension: 'img', size: { original: 999999999, final: { @@ -610,6 +709,7 @@ describe('Browser: SelectionState', function() { SelectionStateModel.setImage({ path: 'foo.img', + extension: 'img', size: { original: 9999999999, final: { @@ -638,6 +738,7 @@ describe('Browser: SelectionState', function() { SelectionStateModel.setImage({ path: 'foo.img', + extension: 'img', size: { original: 999999999, final: { @@ -680,6 +781,7 @@ describe('Browser: SelectionState', function() { SelectionStateModel.setImage({ path: imagePath, + extension: 'img', size: { original: 999999999, final: { @@ -713,6 +815,7 @@ describe('Browser: SelectionState', function() { SelectionStateModel.setImage({ path: 'foo.img', + extension: 'img', size: { original: 999999999, final: { diff --git a/tests/gui/pages/main.spec.js b/tests/gui/pages/main.spec.js index 7fc49ee8..fa590338 100644 --- a/tests/gui/pages/main.spec.js +++ b/tests/gui/pages/main.spec.js @@ -46,6 +46,7 @@ describe('Browser: MainPage', function() { SelectionStateModel.setImage({ path: 'rpi.img', + extension: 'img', size: { original: 99999, final: { @@ -80,6 +81,7 @@ describe('Browser: MainPage', function() { SelectionStateModel.clear(); SelectionStateModel.setImage({ path: 'rpi.img', + extension: 'img', size: { original: 99999, final: { @@ -133,6 +135,7 @@ describe('Browser: MainPage', function() { SelectionStateModel.setImage({ path: 'rpi.img', + extension: 'img', size: { original: 99999, final: { @@ -178,6 +181,7 @@ describe('Browser: MainPage', function() { SelectionStateModel.setImage({ path: path.join(__dirname, 'foo', 'bar.img'), + extension: 'img', size: { original: 999999999, final: { diff --git a/tests/image-stream/bz2.spec.js b/tests/image-stream/bz2.spec.js index ab7ab073..49ae9337 100644 --- a/tests/image-stream/bz2.spec.js +++ b/tests/image-stream/bz2.spec.js @@ -48,6 +48,8 @@ describe('ImageStream: BZ2', function() { return imageStream.getImageMetadata(image).then((metadata) => { m.chai.expect(metadata).to.deep.equal({ path: image, + extension: 'img', + archiveExtension: 'bz2', size: { original: expectedSize, final: { diff --git a/tests/image-stream/dmg.spec.js b/tests/image-stream/dmg.spec.js index 4f0d92da..e0c30123 100644 --- a/tests/image-stream/dmg.spec.js +++ b/tests/image-stream/dmg.spec.js @@ -51,6 +51,7 @@ describe('ImageStream: DMG', function() { return imageStream.getImageMetadata(image).then((metadata) => { m.chai.expect(metadata).to.deep.equal({ path: image, + extension: 'dmg', size: { original: compressedSize, final: { @@ -88,6 +89,7 @@ describe('ImageStream: DMG', function() { return imageStream.getImageMetadata(image).then((metadata) => { m.chai.expect(metadata).to.deep.equal({ path: image, + extension: 'dmg', size: { original: compressedSize, final: { diff --git a/tests/image-stream/gz.spec.js b/tests/image-stream/gz.spec.js index edb61fbf..fa55bb43 100644 --- a/tests/image-stream/gz.spec.js +++ b/tests/image-stream/gz.spec.js @@ -49,6 +49,8 @@ describe('ImageStream: GZ', function() { return imageStream.getImageMetadata(image).then((metadata) => { m.chai.expect(metadata).to.deep.equal({ path: image, + extension: 'img', + archiveExtension: 'gz', size: { original: compressedSize, final: { diff --git a/tests/image-stream/img.spec.js b/tests/image-stream/img.spec.js index 18b61d22..8338e6f5 100644 --- a/tests/image-stream/img.spec.js +++ b/tests/image-stream/img.spec.js @@ -47,6 +47,7 @@ describe('ImageStream: IMG', function() { return imageStream.getImageMetadata(image).then((metadata) => { m.chai.expect(metadata).to.deep.equal({ path: image, + extension: 'img', size: { original: expectedSize, final: { diff --git a/tests/image-stream/iso.spec.js b/tests/image-stream/iso.spec.js index cecd5c8c..8d95aed5 100644 --- a/tests/image-stream/iso.spec.js +++ b/tests/image-stream/iso.spec.js @@ -47,6 +47,7 @@ describe('ImageStream: ISO', function() { return imageStream.getImageMetadata(image).then((metadata) => { m.chai.expect(metadata).to.deep.equal({ path: image, + extension: 'iso', size: { original: expectedSize, final: { diff --git a/tests/image-stream/tester.js b/tests/image-stream/tester.js index e1b0e284..d8f2949e 100644 --- a/tests/image-stream/tester.js +++ b/tests/image-stream/tester.js @@ -60,6 +60,8 @@ exports.extractFromFilePath = function(file, image) { return imageStream.getFromFilePath(file).then(function(results) { m.chai.expect(results.path).to.equal(file); + m.chai.expect(_.isString(results.extension)).to.be.true; + m.chai.expect(_.isEmpty(_.trim(results.extension))).to.be.false; if (!_.some([ results.size.original === fs.statSync(file).size, diff --git a/tests/image-stream/xz.spec.js b/tests/image-stream/xz.spec.js index 3b49f18b..02a58dd6 100644 --- a/tests/image-stream/xz.spec.js +++ b/tests/image-stream/xz.spec.js @@ -49,6 +49,8 @@ describe('ImageStream: XZ', function() { return imageStream.getImageMetadata(image).then((metadata) => { m.chai.expect(metadata).to.deep.equal({ path: image, + extension: 'img', + archiveExtension: 'xz', size: { original: compressedSize, final: { diff --git a/tests/image-stream/zip.spec.js b/tests/image-stream/zip.spec.js index 6aa9213d..4aa556d8 100644 --- a/tests/image-stream/zip.spec.js +++ b/tests/image-stream/zip.spec.js @@ -72,6 +72,8 @@ describe('ImageStream: ZIP', function() { return imageStream.getImageMetadata(image).then((metadata) => { m.chai.expect(metadata).to.deep.equal({ path: image, + extension: 'img', + archiveExtension: 'zip', size: { original: expectedSize, final: { diff --git a/tests/shared/file-extensions.spec.js b/tests/shared/file-extensions.spec.js new file mode 100644 index 00000000..240aa817 --- /dev/null +++ b/tests/shared/file-extensions.spec.js @@ -0,0 +1,141 @@ +/* + * 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 fileExtensions = require('../../lib/shared/file-extensions'); + +describe('Shared: fileExtensions', function() { + + describe('.getFileExtensions()', function() { + + _.forEach([ + + // No extension + { + file: 'path/to/filename', + extension: [] + }, + + // Type: 'archive' + { + file: 'path/to/filename.zip', + extension: [ 'zip' ] + }, + { + file: 'path/to/filename.etch', + extension: [ 'etch' ] + }, + + // Type: 'compressed' + { + file: 'path/to/filename.img.gz', + extension: [ 'img', 'gz' ] + }, + { + file: 'path/to/filename.img.bz2', + extension: [ 'img', 'bz2' ] + }, + { + file: 'path/to/filename.img.xz', + extension: [ 'img', 'xz' ] + }, + + // Type: 'image' + { + file: 'path/to/filename.img', + extension: [ 'img' ] + }, + { + file: 'path/to/filename.iso', + extension: [ 'iso' ] + }, + { + file: 'path/to/filename.dsk', + extension: [ 'dsk' ] + }, + { + file: 'path/to/filename.hddimg', + extension: [ 'hddimg' ] + }, + { + file: 'path/to/filename.raw', + extension: [ 'raw' ] + }, + { + file: 'path/to/filename.dmg', + extension: [ 'dmg' ] + } + + ], (testCase) => { + it(`should return ${testCase.extension} for ${testCase.file}`, function() { + m.chai.expect(fileExtensions.getFileExtensions(testCase.file)).to.deep.equal(testCase.extension); + }); + }); + + it('should always return lowercase extensions', function() { + const filePath = 'foo.IMG.gZ'; + m.chai.expect(fileExtensions.getFileExtensions(filePath)).to.deep.equal([ + 'img', + 'gz' + ]); + }); + + }); + + describe('.getLastFileExtension()', function() { + + it('should return undefined in the file path has no extension', function() { + m.chai.expect(fileExtensions.getLastFileExtension('foo')).to.be.undefined; + }); + + it('should return the extension if there is only one extension', function() { + m.chai.expect(fileExtensions.getLastFileExtension('foo.img')).to.equal('img'); + }); + + it('should return the last extension if there two extensions', function() { + m.chai.expect(fileExtensions.getLastFileExtension('foo.img.gz')).to.equal('gz'); + }); + + it('should return the last extension if there are three extensions', function() { + m.chai.expect(fileExtensions.getLastFileExtension('foo.bar.img.gz')).to.equal('gz'); + }); + + }); + + describe('.getPenultimateFileExtension()', function() { + + it('should return undefined in the file path has no extension', function() { + m.chai.expect(fileExtensions.getPenultimateFileExtension('foo')).to.be.undefined; + }); + + it('should return undefined if there is only one extension', function() { + m.chai.expect(fileExtensions.getPenultimateFileExtension('foo.img')).to.be.undefined; + }); + + it('should return the first extension if there are two extensions', function() { + m.chai.expect(fileExtensions.getPenultimateFileExtension('foo.img.gz')).to.equal('img'); + }); + + it('should return the penultimate extension if there are three extensions', function() { + m.chai.expect(fileExtensions.getPenultimateFileExtension('foo.bar.img.gz')).to.equal('img'); + }); + + }); + +});