Adding make_disk_image subcommand.

These additions to bpttool will satisfy a portion of the provisioning
stage.  This will create support for disk image creation on uefi boards.

TEST=Added unit tests to check written partitions patterns and other
boundary checks.  A manual test involving setting up a loop device
(losetup) with the produced image followed by checking its partitions
(gnome-disks) was also done.
BUG=29123391

Change-Id: Ifa8d4789cefa1e6049e5726ee0b6d4ebaedffb48
diff --git a/README b/README
index b4e6049..785dac8 100644
--- a/README
+++ b/README
@@ -129,7 +129,7 @@
 base-2 units (KiB, MiB, GiB, TiB, PiB) are also supported. For
 example:
 
- "size": "1 Mib"
+ "size": "1 MiB"
 
 means 1,048,576 bytes and
 
@@ -191,6 +191,30 @@
 "system_b" (for the default A/B suffixes) then new_output.bpt would
 contain partitions "system-A", "system-B", and "system-C".
 
+-- DISK IMAGE GENERATION
+
+Disk images may be created given an unfolded .bpt file. 'bpttool
+make_disk_image' generates the output disk image file.
+
+To generate a disk image, use the following subcommand:
+
+  $ bpttool make_disk_image \
+      --output disk-image.bin \
+      --input /path/to/bpt-file.bpt \
+      --image system_a:/path/to/system.img \
+      --image boot_a:/path/to/boot.img \
+      [...]
+
+where the 'output' argument specifies the name and location of the outputted
+disk image and the 'input' argument is the .bpt file containing valid labels and
+offsets for each partition.  The 'image' argument specifies a mapping  from
+partition name/label to the path of the corresponding image partition image.
+All partitions specified in the .bpt file must be passed in via the 'image'
+argument.
+
+Typically, each of the 'image' argument files are located in the
+ANDROID_PRODUCT_OUT directory after a build is complete.
+
 -- BUILD SYSTEM INTEGRATION NOTES
 
 To generate partition tables in the Android build system, simply add
diff --git a/bpt_unittest.py b/bpt_unittest.py
index c2aa539..cc1d925 100755
--- a/bpt_unittest.py
+++ b/bpt_unittest.py
@@ -20,6 +20,7 @@
 
 import imp
 import sys
+import tempfile
 import unittest
 
 sys.dont_write_bytecode = True
@@ -39,6 +40,17 @@
     uuid = '01234567-89ab-cdef-0123-%012x' % partition_number
     return uuid
 
+class PatternPartition(object):
+  """A partition image file containing a predictable pattern.
+
+  This holds file data about a partition image file for binary pattern.
+  testing.
+  """
+  def __init__(self, char='', file=None, partition_name=None, obj=None):
+    self.char = char
+    self.file = file
+    self.partition_name = partition_name
+    self.obj = obj
 
 class RoundToMultipleTest(unittest.TestCase):
   """Unit tests for the RoundToMultiple() function."""
@@ -111,6 +123,108 @@
     self.assertEqual(bpttool.ParseSize('0.5 GiB'), 536870912)
     self.assertEqual(bpttool.ParseSize('0.1 MiB'), 104858)
 
+class MakeDiskImageTest(unittest.TestCase):
+  """Unit tests for 'bpttool make_disk_image'."""
+
+  def setUp(self):
+    """Set-up method."""
+    self.bpt = bpttool.Bpt()
+
+  def _BinaryPattern(self, bpt_file_name, partition_patterns):
+    """Checks that a binary pattern may be written to a specified partition.
+
+    This checks individual partion image writes to portions of a disk.  Known
+    patterns are written into certain partitions and are verified after each
+    pattern has been written to.
+
+    Arguments:
+      bpt_file_name: File name of bpt JSON containing partition information.
+      partition_patterns: List of tuples with each tuple having partition name
+                          as the first argument, and character pattern as the
+                          second argument.
+
+    """
+    bpt_file = open(bpt_file_name, 'r')
+    partitions_string, _ = self.bpt.make_table([bpt_file])
+    bpt_tmp = tempfile.NamedTemporaryFile()
+    bpt_tmp.write(partitions_string)
+    bpt_tmp.seek(0)
+    partitions, _ = self.bpt._read_json([bpt_tmp])
+
+    # Declare list of partition images to be written and compared on disk.
+    pattern_images = [PatternPartition(
+                      char=pp[1],
+                      file=tempfile.NamedTemporaryFile(),
+                      partition_name=pp[0])
+                      for pp in partition_patterns]
+
+    # Store partition object and write a known character pattern image.
+    for pi in pattern_images:
+      pi.obj = [p for p in partitions if str(p.label) == pi.partition_name][0]
+      pi.file.write(bytearray(pi.char * int(pi.obj.size)))
+
+    # Create the disk containing the partition filled with a known character
+    # pattern, seek to it's position and compare it to the supposed pattern.
+    with tempfile.NamedTemporaryFile() as generated_disk_image:
+      bpt_tmp.seek(0)
+      self.bpt.make_disk_image(generated_disk_image,
+                               bpt_tmp,
+                               [p.partition_name + ':' + p.file.name
+                                for p in pattern_images])
+
+      for pi in pattern_images:
+        generated_disk_image.seek(pi.obj.offset)
+        pi.file.seek(0)
+
+        self.assertEqual(generated_disk_image.read(pi.obj.size),
+                    pi.file.read())
+        pi.file.close()
+
+    bpt_file.close()
+    bpt_tmp.close()
+
+  def _LargeBinary(self, bpt_file_name):
+    """Helper function to write large partition images to disk images.
+
+    This is a simple call to make_disk_image, passing a large in an image
+    which exceeds the it's size as specfied in the bpt file.
+
+    Arguments:
+      bpt_file_name: File name of bpt JSON containing partition information.
+
+    """
+    with open(bpt_file_name, 'r') as bpt_file, \
+         tempfile.NamedTemporaryFile() as bpt_tmp, \
+         tempfile.NamedTemporaryFile() as generated_disk_image, \
+         tempfile.NamedTemporaryFile() as large_partition_image:
+        partitions_string, _ = self.bpt.make_table([bpt_file])
+        bpt_tmp.write(partitions_string)
+        bpt_tmp.seek(0)
+        partitions, _ = self.bpt._read_json([bpt_tmp])
+
+        # Create the over-sized partition image.
+        large_partition_image.write(bytearray('0' *
+          int(1.1*partitions[0].size + 1)))
+
+        bpt_tmp.seek(0)
+
+        # Expect exception here.
+        self.bpt.make_disk_image(generated_disk_image, bpt_tmp,
+          [p.label + ':' + large_partition_image.name for p in partitions])
+
+  def testBinaryPattern(self):
+    """Checks patterns written to partitions on disk images."""
+    self._BinaryPattern('test/pattern_partition_single.bpt', [('charlie', 'c')])
+    self._BinaryPattern('test/pattern_partition_multi.bpt', [('alpha', 'a'),
+                        ('beta', 'b')])
+
+  def testExceedPartitionSize(self):
+    """Checks that exceedingly large partition images are not accepted."""
+    try:
+      self._LargeBinary('test/pattern_partition_exceed_size.bpt')
+    except bpttool.BptError as e:
+      assert 'exceeds the partition size' in e.message
+
 
 class MakeTableTest(unittest.TestCase):
   """Unit tests for 'bpttool make_table'."""
diff --git a/bpttool b/bpttool
index 861fe6b..3e57695 100755
--- a/bpttool
+++ b/bpttool
@@ -677,6 +677,31 @@
     ret = protective_mbr + primary_gpt + secondary_gpt
     return ret
 
+  def _validate_disk_partitions(self, partitions, disk_size):
+    """Check that a list of partitions have assigned offsets and fits on a
+       disk of a given size.
+
+    This function checks partition offsets and sizes to see if they may fit on
+    a disk image.
+
+    Arguments:
+      partitions: A list of Partition objects.
+      settings: Integer size of disk image.
+
+    Raises:
+      BptError: If checked condition is not satisfied.
+    """
+    for p in partitions:
+      if not p.offset or p.offset < (GPT_NUM_LBAS + 1)*DISK_SECTOR_SIZE:
+        raise BptError('Partition with label "{}" has no offset.'
+                       .format(p.label))
+      if not p.size or p.size < 0:
+        raise BptError('Partition with label "{}" has no size.'
+                        .format(p.label))
+      if (p.offset + p.size) > (disk_size - GPT_NUM_LBAS*DISK_SECTOR_SIZE):
+        raise BptError('Partition with label "{}" exceeds the disk '
+                       'image size.'.format(p.label))
+
   def make_table(self,
                  inputs,
                  ab_suffixes=None,
@@ -798,7 +823,7 @@
                      'totaling {} bytes.\n'.format(
                          settings.disk_size, offset))
 
-    # If we have an grow partition, it'll starts at the next
+    # If we have a grow partition, it'll starts at the next
     # available alignment offset and we can calculate its size as
     # follows.
     if grow_part:
@@ -827,6 +852,78 @@
 
     return json_str, gpt_bin
 
+  def make_disk_image(self, output, bpt, images, allow_empty_partitions=False):
+    """Implementation of the 'make_disk_image' command.
+
+    This function takes in a list of partitions images and a bpt file
+    for the purpose of creating a raw disk image with a protective MBR,
+    primary and secondary GPT, and content for each partition as specified.
+
+    Arguments:
+      output: Output file where disk image is to be written to.
+      bpt: BPT JSON file to parse.
+      images: List of partition image paths to be combined (as specified by
+              bpt).  Each element is of the form.
+              'PARTITION_NAME:/PATH/TO/PARTITION_IMAGE'
+      allow_empty_partitions: If True, partitions defined in |bpt| need not to
+                              be present in |images|. Otherwise an exception is
+                              thrown if a partition is referenced in |bpt| but
+                              not in |images|.
+
+    Raises:
+      BptParsingError: If an image file has an error.
+      BptError: If another application-specific error occurs.
+    """
+    # Generate partition list and settings.
+    partitions, settings = self._read_json([bpt], ab_collapse=False)
+
+    # Validated partition sizes and offsets.
+    self._validate_disk_partitions(partitions, settings.disk_size)
+
+    # Sort according to 'offset' attribute.
+    partitions = sorted(partitions, cmp=lambda x, y: cmp(x.offset, y.offset))
+
+    # Create necessary tables.
+    protective_mbr = self._generate_protective_mbr(settings)
+    primary_gpt = self._generate_gpt(partitions, settings)
+    secondary_gpt = self._generate_gpt(partitions, settings, primary=False)
+
+    # Start at 0 offset for mbr and primary gpt.
+    output.seek(0)
+    output.write(protective_mbr)
+    output.write(primary_gpt)
+
+    # Create mapping of partition name to partition image file.
+    image_file_names = {}
+    try:
+      for name_path in images:
+        name, path = name_path.split(":")
+        image_file_names[name] = path
+    except ValueError as e:
+      raise BptParsingError(name_path, 'Bad image argument {}.'.format(
+                            images[i]))
+
+    # Read image and insert in correct offset.
+    for p in partitions:
+      if p.label not in image_file_names:
+        if allow_empty_partitions:
+          continue
+        else:
+          raise BptParsingError(bpt.name, 'No content specified for partition'
+                                ' with label {}'.format(p.label))
+
+      with open(image_file_names[p.label], 'rb') as partition_image:
+        output.seek(p.offset)
+        partition_blob = partition_image.read()
+        if len(partition_blob) > p.size:
+          raise BptError('Partition image content with label "{}" exceeds the '
+                         'partition size.'.format(p.label))
+        output.write(partition_blob)
+
+    # Put secondary GPT and end of disk.
+    output.seek(settings.disk_size - len(secondary_gpt))
+    output.write(secondary_gpt)
+
   def query_partition(self, input_file, part_label, query_type, ab_collapse):
     """Implementation of the 'query_partition' command.
 
@@ -849,7 +946,7 @@
       BptError: If another application-specific error occurs
     """
 
-    partitions, _ = self._read_json([input_file], ab_collapse)
+    partitions, _ = self._read_json([input_file], 'ab_collapse')
 
     part = None
     for p in partitions:
@@ -915,6 +1012,26 @@
     sub_parser.set_defaults(func=self.make_table)
 
     sub_parser = subparsers.add_parser(
+        'make_disk_image',
+        help='Creates disk image for loaded with partitions.')
+    sub_parser.add_argument('--output',
+                            help='Path to image output.',
+                            type=argparse.FileType('w'),
+                            required=True)
+    sub_parser.add_argument('--input',
+                            help='Path to bpt file input.',
+                            type=argparse.FileType('r'),
+                            required=True)
+    sub_parser.add_argument('--image',
+                            help='Partition name and path to image file.',
+                            metavar='PARTITION_NAME:PATH',
+                            action='append')
+    sub_parser.add_argument('--allow_empty_partitions',
+                            help='Allow skipping partitions in bpt file.',
+                            action='store_false')
+    sub_parser.set_defaults(func=self.make_disk_image)
+
+    sub_parser = subparsers.add_parser(
         'query_partition',
         help='Looks up informtion about a partition.')
     sub_parser.add_argument('--input',
@@ -983,6 +1100,26 @@
     if args.output_gpt:
       args.output_gpt.write(gpt_bin)
 
+  def make_disk_image(self, args):
+    """Implements the 'make_disk_image' sub-command."""
+    if not args.input:
+      sys.stderr.write('Option --input is required.\n')
+      sys.exit(1)
+    if not args.output:
+      sys.stderr.write('Option --ouptut is required.\n')
+      sys.exit(1)
+
+    try:
+      self.bpt.make_disk_image(args.output,
+                               args.input,
+                               args.image,
+                               args.allow_empty_partitions)
+    except BptParsingError as e:
+      sys.stderr.write('{}: Error parsing: {}\n'.format(e.filename, e.message))
+      sys.exit(1)
+    except 'BptError' as e:
+      sys.stderr.write('{}\n'.format(e.message))
+      sys.exit(1)
 
 if __name__ == '__main__':
   tool = BptTool()
diff --git a/test/pattern_partition_exceed_size.bpt b/test/pattern_partition_exceed_size.bpt
new file mode 100644
index 0000000..c1c54c0
--- /dev/null
+++ b/test/pattern_partition_exceed_size.bpt
@@ -0,0 +1,11 @@
+{
+    "settings": {
+        "disk_size": "50 MiB"
+    },
+    "partitions": [
+        {
+            "label": "delta",
+            "size": "20 MiB"
+        }
+    ]
+}
diff --git a/test/pattern_partition_multi.bpt b/test/pattern_partition_multi.bpt
new file mode 100644
index 0000000..56cd061
--- /dev/null
+++ b/test/pattern_partition_multi.bpt
@@ -0,0 +1,15 @@
+{
+    "settings": {
+        "disk_size": "80 MiB"
+    },
+    "partitions": [
+        {
+            "label": "alpha",
+            "size": "10 MiB"
+        },
+        {
+            "label": "beta",
+            "size": "50 MiB"
+        }
+    ]
+}
diff --git a/test/pattern_partition_single.bpt b/test/pattern_partition_single.bpt
new file mode 100644
index 0000000..036de77
--- /dev/null
+++ b/test/pattern_partition_single.bpt
@@ -0,0 +1,11 @@
+{
+    "settings": {
+        "disk_size": "40 GiB"
+    },
+    "partitions": [
+        {
+            "label": "charlie",
+            "size": "10 MiB"
+        }
+    ]
+}