mirror of
https://github.com/balena-io/etcher.git
synced 2025-07-24 11:46:31 +00:00
feat(image-stream): Read MBR & GPT in .getImageMetadata() (#1248)
* 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
This commit is contained in:
parent
b18fa1f13f
commit
80b588683e
@ -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) {
|
||||
|
@ -63,7 +63,7 @@ module.exports = {
|
||||
value: options.size
|
||||
}
|
||||
},
|
||||
transform: Bluebird.resolve(unbzip2Stream())
|
||||
transform: unbzip2Stream()
|
||||
});
|
||||
},
|
||||
|
||||
@ -94,7 +94,7 @@ module.exports = {
|
||||
value: uncompressedSize
|
||||
}
|
||||
},
|
||||
transform: Bluebird.resolve(zlib.createGunzip())
|
||||
transform: zlib.createGunzip()
|
||||
});
|
||||
});
|
||||
},
|
||||
@ -215,7 +215,7 @@ module.exports = {
|
||||
value: options.size
|
||||
}
|
||||
},
|
||||
transform: Bluebird.resolve(new PassThroughStream())
|
||||
transform: new PassThroughStream()
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
|
212
lib/image-stream/parse-partitions.js
Normal file
212
lib/image-stream/parse-partitions.js
Normal file
@ -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);
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
};
|
@ -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(' '))
|
||||
|
||||
},
|
||||
|
15
npm-shrinkwrap.json
generated
15
npm-shrinkwrap.json
generated
@ -885,6 +885,11 @@
|
||||
"resolved": "https://registry.npmjs.org/chromium-pickle-js/-/chromium-pickle-js-0.1.0.tgz",
|
||||
"dev": true
|
||||
},
|
||||
"chs": {
|
||||
"version": "1.1.0",
|
||||
"from": "chs@>=1.1.0 <1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/chs/-/chs-1.1.0.tgz"
|
||||
},
|
||||
"ci-info": {
|
||||
"version": "1.0.0",
|
||||
"from": "ci-info@>=1.0.0 <2.0.0",
|
||||
@ -2869,6 +2874,11 @@
|
||||
"resolved": "https://registry.npmjs.org/got/-/got-6.7.1.tgz",
|
||||
"dev": true
|
||||
},
|
||||
"gpt": {
|
||||
"version": "1.0.0",
|
||||
"from": "gpt@latest",
|
||||
"resolved": "https://registry.npmjs.org/gpt/-/gpt-1.0.0.tgz"
|
||||
},
|
||||
"graceful-fs": {
|
||||
"version": "4.1.11",
|
||||
"from": "graceful-fs@>=4.1.2 <5.0.0",
|
||||
@ -4619,6 +4629,11 @@
|
||||
"resolved": "https://registry.npmjs.org/markdown-utils/-/markdown-utils-0.7.3.tgz",
|
||||
"dev": true
|
||||
},
|
||||
"mbr": {
|
||||
"version": "1.1.2",
|
||||
"from": "mbr@latest",
|
||||
"resolved": "https://registry.npmjs.org/mbr/-/mbr-1.1.2.tgz"
|
||||
},
|
||||
"mem": {
|
||||
"version": "1.1.0",
|
||||
"from": "mem@>=1.1.0 <2.0.0",
|
||||
|
@ -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",
|
||||
|
@ -62,7 +62,10 @@ describe('ImageStream: BZ2', function() {
|
||||
estimation: true,
|
||||
value: expectedSize
|
||||
}
|
||||
}
|
||||
},
|
||||
hasMBR: true,
|
||||
hasGPT: false,
|
||||
partitions: require('./data/images/etcher-test-partitions.json')
|
||||
});
|
||||
});
|
||||
});
|
||||
|
BIN
tests/image-stream/data/dmg/raspberrypi-compressed.dmg
Normal file
BIN
tests/image-stream/data/dmg/raspberrypi-compressed.dmg
Normal file
Binary file not shown.
BIN
tests/image-stream/data/dmg/raspberrypi-raw.dmg
Normal file
BIN
tests/image-stream/data/dmg/raspberrypi-raw.dmg
Normal file
Binary file not shown.
@ -0,0 +1,26 @@
|
||||
[
|
||||
{
|
||||
"type": "E3C9E316-0B5C-4DB8-817D-F92DF00215AE",
|
||||
"id": "F2020024-6D12-43A7-B0AA-0E243771ED00",
|
||||
"name": "Microsoft reserved partition",
|
||||
"firstLBA": 34,
|
||||
"lastLBA": 65569,
|
||||
"extended": false
|
||||
},
|
||||
{
|
||||
"type": "EBD0A0A2-B9E5-4433-87C0-68B6B72699C7",
|
||||
"id": "3B781D99-BEFA-41F7-85C7-01346507805C",
|
||||
"name": "Basic data partition",
|
||||
"firstLBA": 65664,
|
||||
"lastLBA": 163967,
|
||||
"extended": false
|
||||
},
|
||||
{
|
||||
"type": "EBD0A0A2-B9E5-4433-87C0-68B6B72699C7",
|
||||
"id": "EE0EAF80-24C1-4A41-949E-419676E89AD6",
|
||||
"name": "Basic data partition",
|
||||
"firstLBA": 163968,
|
||||
"lastLBA": 258175,
|
||||
"extended": false
|
||||
}
|
||||
]
|
BIN
tests/image-stream/data/images/etcher-gpt-test.img.gz
Normal file
BIN
tests/image-stream/data/images/etcher-gpt-test.img.gz
Normal file
Binary file not shown.
34
tests/image-stream/data/images/etcher-test-partitions.json
Normal file
34
tests/image-stream/data/images/etcher-test-partitions.json
Normal file
@ -0,0 +1,34 @@
|
||||
[
|
||||
{
|
||||
"type": 14,
|
||||
"id": null,
|
||||
"name": null,
|
||||
"firstLBA": 128,
|
||||
"lastLBA": 2176,
|
||||
"extended": false
|
||||
},
|
||||
{
|
||||
"type": 14,
|
||||
"id": null,
|
||||
"name": null,
|
||||
"firstLBA": 2176,
|
||||
"lastLBA": 4224,
|
||||
"extended": false
|
||||
},
|
||||
{
|
||||
"type": 0,
|
||||
"id": null,
|
||||
"name": null,
|
||||
"firstLBA": 0,
|
||||
"lastLBA": 0,
|
||||
"extended": false
|
||||
},
|
||||
{
|
||||
"type": 0,
|
||||
"id": null,
|
||||
"name": null,
|
||||
"firstLBA": 0,
|
||||
"lastLBA": 0,
|
||||
"extended": false
|
||||
}
|
||||
]
|
@ -91,7 +91,10 @@ describe('ImageStream: DMG', function() {
|
||||
estimation: false,
|
||||
value: uncompressedSize
|
||||
}
|
||||
}
|
||||
},
|
||||
hasMBR: true,
|
||||
hasGPT: false,
|
||||
partitions: require('./data/images/etcher-test-partitions.json')
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -129,7 +132,10 @@ describe('ImageStream: DMG', function() {
|
||||
estimation: false,
|
||||
value: uncompressedSize
|
||||
}
|
||||
}
|
||||
},
|
||||
hasMBR: true,
|
||||
hasGPT: false,
|
||||
partitions: require('./data/images/etcher-test-partitions.json')
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -57,7 +57,10 @@ describe('ImageStream: GZ', function() {
|
||||
estimation: true,
|
||||
value: uncompressedSize
|
||||
}
|
||||
}
|
||||
},
|
||||
hasMBR: true,
|
||||
hasGPT: false,
|
||||
partitions: require('./data/images/etcher-test-partitions.json')
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -40,23 +40,58 @@ describe('ImageStream: IMG', function() {
|
||||
|
||||
describe('.getImageMetadata()', function() {
|
||||
|
||||
it('should return the correct metadata', function() {
|
||||
const image = path.join(IMAGES_PATH, 'etcher-test.img');
|
||||
const expectedSize = fs.statSync(image).size;
|
||||
context('Master Boot Record', function() {
|
||||
|
||||
return imageStream.getImageMetadata(image).then((metadata) => {
|
||||
m.chai.expect(metadata).to.deep.equal({
|
||||
path: image,
|
||||
extension: 'img',
|
||||
size: {
|
||||
original: expectedSize,
|
||||
final: {
|
||||
estimation: false,
|
||||
value: expectedSize
|
||||
}
|
||||
}
|
||||
it('should return the correct metadata', function() {
|
||||
const image = path.join(IMAGES_PATH, 'etcher-test.img');
|
||||
const expectedSize = fs.statSync(image).size;
|
||||
|
||||
return imageStream.getImageMetadata(image).then((metadata) => {
|
||||
m.chai.expect(metadata).to.deep.equal({
|
||||
path: image,
|
||||
extension: 'img',
|
||||
size: {
|
||||
original: expectedSize,
|
||||
final: {
|
||||
estimation: false,
|
||||
value: expectedSize
|
||||
}
|
||||
},
|
||||
hasMBR: true,
|
||||
hasGPT: false,
|
||||
partitions: require('./data/images/etcher-test-partitions.json')
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
context('GUID Partition Table', function() {
|
||||
|
||||
it('should return the correct metadata', function() {
|
||||
const image = path.join(IMAGES_PATH, 'etcher-gpt-test.img.gz');
|
||||
const uncompressedSize = 134217728;
|
||||
const expectedSize = fs.statSync(image).size;
|
||||
|
||||
return imageStream.getImageMetadata(image).then((metadata) => {
|
||||
m.chai.expect(metadata).to.deep.equal({
|
||||
path: image,
|
||||
extension: 'img',
|
||||
archiveExtension: 'gz',
|
||||
size: {
|
||||
original: expectedSize,
|
||||
final: {
|
||||
estimation: true,
|
||||
value: uncompressedSize
|
||||
}
|
||||
},
|
||||
hasMBR: true,
|
||||
hasGPT: true,
|
||||
partitions: require('./data/images/etcher-gpt-test-partitions.json')
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
@ -54,7 +54,10 @@ describe('ImageStream: ISO', function() {
|
||||
estimation: false,
|
||||
value: expectedSize
|
||||
}
|
||||
}
|
||||
},
|
||||
hasMBR: true,
|
||||
hasGPT: false,
|
||||
partitions: require('./data/images/etcher-test-partitions.json')
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -57,7 +57,10 @@ describe('ImageStream: XZ', function() {
|
||||
estimation: false,
|
||||
value: uncompressedSize
|
||||
}
|
||||
}
|
||||
},
|
||||
hasMBR: true,
|
||||
hasGPT: false,
|
||||
partitions: require('./data/images/etcher-test-partitions.json')
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -122,7 +122,10 @@ describe('ImageStream: ZIP', function() {
|
||||
estimation: false,
|
||||
value: expectedSize
|
||||
}
|
||||
}
|
||||
},
|
||||
hasMBR: true,
|
||||
hasGPT: false,
|
||||
partitions: require('./data/images/etcher-test-partitions.json')
|
||||
});
|
||||
});
|
||||
});
|
||||
|
Loading…
x
Reference in New Issue
Block a user