Skip to content

Commit

Permalink
feat(image-stream): Read MBR & GPT in .getImageMetadata() (#1248)
Browse files Browse the repository at this point in the history
* feat(image-stream): Read MBR & GPT in .getImageMetadata()
* feat(gui): Display warning when image has no MBR
* test(image-stream): Update .isSupportedImage() tests
* feat(image-stream): Normalize MBR & GPT partitions
* test(image-stream): Add partition info
* feat(image-selection): Send missing part table event
* test(image-stream): Add GPT test image

Change-Type: minor
  • Loading branch information
jhermsmeier authored Jul 5, 2017
1 parent b18fa1f commit 80b5886
Show file tree
Hide file tree
Showing 19 changed files with 402 additions and 44 deletions.
29 changes: 19 additions & 10 deletions lib/gui/pages/main/controllers/image-selection.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,19 +81,28 @@ module.exports = function(
}

Bluebird.try(() => {
if (!supportedFormats.looksLikeWindowsImage(image.path)) {
return false;
let message = null;

if (supportedFormats.looksLikeWindowsImage(image)) {
analytics.logEvent('Possibly Windows image', image);
message = messages.warning.looksLikeWindowsImage();
} else if (supportedFormats.missingPartitionTable(image)) {
analytics.logEvent('Missing partition table', image);
message = messages.warning.missingPartitionTable();
}

analytics.logEvent('Possibly Windows image', image);
if (message) {
// TODO: `Continue` should be on a red background (dangerous action) instead of `Change`.
// We want `X` to act as `Continue`, that's why `Continue` is the `rejectionLabel`
return WarningModalService.display({
confirmationLabel: 'Change',
rejectionLabel: 'Continue',
description: message
});
}

return false;

// TODO: `Continue` should be on a red background (dangerous action) instead of `Change`.
// We want `X` to act as `Continue`, that's why `Continue` is the `rejectionLabel`
return WarningModalService.display({
confirmationLabel: 'Change',
rejectionLabel: 'Continue',
description: messages.warning.looksLikeWindowsImage()
});
}).then((shouldChange) => {

if (shouldChange) {
Expand Down
6 changes: 3 additions & 3 deletions lib/image-stream/handlers.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ module.exports = {
value: options.size
}
},
transform: Bluebird.resolve(unbzip2Stream())
transform: unbzip2Stream()
});
},

Expand Down Expand Up @@ -94,7 +94,7 @@ module.exports = {
value: uncompressedSize
}
},
transform: Bluebird.resolve(zlib.createGunzip())
transform: zlib.createGunzip()
});
});
},
Expand Down Expand Up @@ -215,7 +215,7 @@ module.exports = {
value: options.size
}
},
transform: Bluebird.resolve(new PassThroughStream())
transform: new PassThroughStream()
});
}

Expand Down
16 changes: 7 additions & 9 deletions lib/image-stream/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const utils = require('./utils');
const handlers = require('./handlers');
const supportedFileTypes = require('./supported');
const errors = require('../shared/errors');
const parsePartitions = require('./parse-partitions');

/**
* @summary Get an image stream from a file
Expand Down Expand Up @@ -116,16 +117,13 @@ exports.getFromFilePath = (file) => {
* });
*/
exports.getImageMetadata = (file) => {
return exports.getFromFilePath(file).then((image) => {

// Since we're not consuming this stream,
// destroy() it, to avoid dangling open file descriptors etc.
image.stream.destroy();

return _.omitBy(image, (property) => {
return property instanceof stream.Stream;
return exports.getFromFilePath(file)
.then(parsePartitions)
.then((image) => {
return _.omitBy(image, (property) => {
return property instanceof stream.Stream || _.isNil(property);
});
});
});
};

/**
Expand Down
212 changes: 212 additions & 0 deletions lib/image-stream/parse-partitions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
/*
* 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');
const Bluebird = require('bluebird');
const MBR = require('mbr');
const GPT = require('gpt');

/**
* @summary Maximum number of bytes to read from the stream
* @type {Number}
* @constant
*/
const MAX_STREAM_BYTES = 65536;

/**
* @summary Initial number of bytes read
* @type {Number}
* @constant
*/
const INITIAL_LENGTH = 0;

/**
* @summary Initial block size
* @type {Number}
* @constant
*/
const INITIAL_BLOCK_SIZE = 512;

/**
* @summary Maximum block size to check for
* @type {Number}
* @constant
*/
const MAX_BLOCK_SIZE = 4096;

/**
* @summary Attempt to parse the GPT from various block sizes
* @function
* @private
*
* @param {Buffer} buffer - Buffer
* @returns {GPT|null}
*
* @example
* const gpt = detectGPT(buffer);
*
* if (gpt != null) {
* // Has a GPT
* console.log('Partitions:', gpt.partitions);
* }
*/
const detectGPT = (buffer) => {

let blockSize = INITIAL_BLOCK_SIZE;
let gpt = null;

// Attempt to parse the GPT from several offsets,
// as the block size of the image may vary (512,1024,2048,4096);
// For example, ISOs will usually have a block size of 4096,
// but raw images a block size of 512 bytes
while (blockSize <= MAX_BLOCK_SIZE) {
gpt = _.attempt(GPT.parse, buffer.slice(blockSize));
if (!_.isError(gpt)) {
return gpt;
}
blockSize += blockSize;
}

return null;

};

/**
* @summary Attempt to parse the MBR & GPT from a given buffer
* @function
* @private
*
* @param {Object} image - Image metadata
* @param {Buffer} buffer - Buffer
*
* @example
* parsePartitionTables(image, buffer);
*
* if (image.hasMBR || image.hasGPT) {
* console.log('Partitions:', image.partitions);
* }
*/
const parsePartitionTables = (image, buffer) => {

const mbr = _.attempt(MBR.parse, buffer);
let gpt = null;

if (!_.isError(mbr)) {
image.hasMBR = true;
gpt = detectGPT(buffer);
image.hasGPT = !_.isNil(gpt);
}

// As MBR and GPT partition entries have a different structure,
// we normalize them here to make them easier to deal with and
// avoid clutter in what's sent to analytics
if (image.hasGPT) {
image.partitions = _.map(gpt.partitions, (partition) => {
return {
type: partition.type.toString(),
id: partition.guid.toString(),
name: partition.name,
firstLBA: partition.firstLBA,
lastLBA: partition.lastLBA,
extended: false
};
});
} else if (image.hasMBR) {
image.partitions = _.map(mbr.partitions, (partition) => {
return {
type: partition.type,
id: null,
name: null,
firstLBA: partition.firstLBA,
lastLBA: partition.lastLBA,
extended: partition.extended
};
});
}

};

/**
* @summary Attempt to read the MBR and GPT from an imagestream
* @function
* @public
* @description
* This operation will consume the first `MAX_STREAM_BYTES`
* of the stream and then destroy the stream.
*
* @param {Object} image - image metadata
* @returns {Promise}
* @fulfil {Object} image
* @reject {Error}
*
* @example
* parsePartitions(image)
* .then((image) => {
* console.log('MBR:', image.hasMBR);
* console.log('GPT:', image.hasGPT);
* console.log('Partitions:', image.partitions);
* });
*/
module.exports = (image) => {
return new Bluebird((resolve, reject) => {

const chunks = [];
let length = INITIAL_LENGTH;
let destroyed = false;

image.hasMBR = false;
image.hasGPT = false;

let stream = image.stream.pipe(image.transform);

stream.on('error', reject);

// We need to use the "old" flowing mode here,
// as some dependencies don't implement the "readable"
// mode properly (i.e. bzip2)
stream.on('data', (chunk) => {
chunks.push(chunk);
length += chunk.length;

// Once we've read enough bytes, terminate the stream
if (length >= MAX_STREAM_BYTES && !destroyed) {

// Prefer close() over destroy(), as some streams
// from dependencies exhibit quirky behavior when destroyed
if (image.stream.close) {
image.stream.close();
} else {
image.stream.destroy();
}

// Remove references to stream to allow them being GCed
image.stream = null;
image.transform = null;
stream = null;
destroyed = true;

// Parse the MBR, GPT and partitions from the obtained buffer
parsePartitionTables(image, Buffer.concat(chunks));
resolve(image);

}
});

});

};
6 changes: 6 additions & 0 deletions lib/shared/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@ module.exports = {
'Unlike other images, Windows images require special processing to be made bootable.',
'We suggest you use a tool specially designed for this purpose, such as',
'<a href="https://rufus.akeo.ie">Rufus</a> (Windows) or Boot Camp Assistant (macOS).'
].join(' ')),

missingPartitionTable: _.template([
'It looks like this is not a bootable image.\n\n',
'The image does not appear to contain a partition table,',
'and might not be recognized or bootable by your device.'
].join(' '))

},
Expand Down
15 changes: 15 additions & 0 deletions npm-shrinkwrap.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,11 @@
"etcher-image-write": "9.1.3",
"file-type": "4.1.0",
"flexboxgrid": "6.3.0",
"gpt": "1.0.0",
"immutable": "3.8.1",
"lodash": "4.13.1",
"lzma-native": "1.5.2",
"mbr": "1.1.2",
"mime-types": "2.1.15",
"mountutils": "1.2.0",
"nan": "2.3.5",
Expand Down
5 changes: 4 additions & 1 deletion tests/image-stream/bz2.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,10 @@ describe('ImageStream: BZ2', function() {
estimation: true,
value: expectedSize
}
}
},
hasMBR: true,
hasGPT: false,
partitions: require('./data/images/etcher-test-partitions.json')
});
});
});
Expand Down
Binary file not shown.
Binary file added tests/image-stream/data/dmg/raspberrypi-raw.dmg
Binary file not shown.
Loading

0 comments on commit 80b5886

Please sign in to comment.