Snap for 8414339 from 2fe7d1583e874b45eb678d10e1450a6e70ff4cb4 to tm-qpr1-release

Change-Id: I618a10d5f9b9d640b13c7036557267447ef400ad
diff --git a/Android.bp b/Android.bp
index 77a5a32..65b6ac2 100644
--- a/Android.bp
+++ b/Android.bp
@@ -91,6 +91,20 @@
     ],
 }
 
+python_binary_host {
+    name: "certify_bootimg",
+    defaults: ["mkbootimg_defaults"],
+    main: "gki/certify_bootimg.py",
+    srcs: [
+        "gki/certify_bootimg.py",
+        "gki/generate_gki_certificate.py",
+        "unpack_bootimg.py",
+    ],
+    required: [
+        "avbtool",
+    ],
+}
+
 python_test_host {
     name: "mkbootimg_test",
     defaults: ["mkbootimg_defaults"],
diff --git a/gki/Android.bp b/gki/Android.bp
index 5173852..c62e7d8 100644
--- a/gki/Android.bp
+++ b/gki/Android.bp
@@ -16,20 +16,6 @@
     default_applicable_licenses: ["Android-Apache-2.0"],
 }
 
-python_binary_host {
-    name: "certify_bootimg",
-    defaults: ["mkbootimg_defaults"],
-    main: "certify_bootimg.py",
-    srcs: [
-        "certify_bootimg.py",
-        "generate_gki_certificate.py",
-    ],
-    required: [
-        "avbtool",
-        "unpack_bootimg",
-    ],
-}
-
 python_test_host {
     name: "certify_bootimg_test",
     defaults: ["mkbootimg_defaults"],
diff --git a/gki/certify_bootimg.py b/gki/certify_bootimg.py
index 1543698..9a7b058 100755
--- a/gki/certify_bootimg.py
+++ b/gki/certify_bootimg.py
@@ -18,12 +18,15 @@
 """Certify a GKI boot image by generating and appending its boot_signature."""
 
 from argparse import ArgumentParser
+import glob
 import os
+import shlex
 import shutil
 import subprocess
 import tempfile
 
-from generate_gki_certificate import generate_gki_certificate
+from gki.generate_gki_certificate import generate_gki_certificate
+from unpack_bootimg import unpack_bootimg
 
 BOOT_SIGNATURE_SIZE = 16 * 1024
 
@@ -31,14 +34,7 @@
 def get_kernel(boot_img):
     """Extracts the kernel from |boot_img| and returns it."""
     with tempfile.TemporaryDirectory() as unpack_dir:
-        unpack_bootimg_cmd = [
-            'unpack_bootimg',
-            '--boot_img', boot_img,
-            '--out', unpack_dir,
-        ]
-        subprocess.run(unpack_bootimg_cmd, check=True,
-                       stdout=subprocess.DEVNULL)
-
+        unpack_bootimg(boot_img, unpack_dir)
         with open(os.path.join(unpack_dir, 'kernel'), 'rb') as kernel:
             kernel_bytes = kernel.read()
             assert len(kernel_bytes) > 0
@@ -150,13 +146,31 @@
     subprocess.check_call(avbtool_cmd)
 
 
+def load_dict_from_file(path):
+    """Loads key=value pairs from |path| and returns a dict."""
+    d = {}
+    with open(path, 'r', encoding='utf-8') as f:
+        for line in f:
+            line = line.strip()
+            if not line or line.startswith('#'):
+                continue
+            if '=' in line:
+                name, value = line.split('=', 1)
+                d[name] = value
+    return d
+
+
 def parse_cmdline():
     """Parse command-line options."""
     parser = ArgumentParser(add_help=True)
 
     # Required args.
-    parser.add_argument('--boot_img', required=True,
-                        help='path to the boot image to certify')
+    input_group = parser.add_mutually_exclusive_group(required=True)
+    input_group.add_argument(
+        '--boot_img', help='path to the boot image to certify')
+    input_group.add_argument(
+        '--boot_img_zip', help='path to the boot-img-*.zip archive to certify')
+
     parser.add_argument('--algorithm', required=True,
                         help='signing algorithm for the certificate')
     parser.add_argument('--key', required=True,
@@ -172,23 +186,60 @@
 
     extra_args = []
     for a in args.extra_args:
-        extra_args.extend(a.split())
+        extra_args.extend(shlex.split(a))
     args.extra_args = extra_args
 
     return args
 
 
+def certify_bootimg(boot_img, output_img, algorithm, key, extra_args):
+    """Certify a GKI boot image by generating and appending a boot_signature."""
+    with tempfile.TemporaryDirectory() as temp_dir:
+        boot_tmp = os.path.join(temp_dir, 'boot.tmp')
+        shutil.copy2(boot_img, boot_tmp)
+
+        erase_certificate_and_avb_footer(boot_tmp)
+        add_certificate(boot_tmp, algorithm, key, extra_args)
+
+        avb_partition_size = get_avb_image_size(boot_img)
+        add_avb_footer(boot_tmp, avb_partition_size)
+
+        # We're done, copy the temp image to the final output.
+        shutil.copy2(boot_tmp, output_img)
+
+
+def certify_bootimg_zip(boot_img_zip, output_zip, algorithm, key, extra_args):
+    """Similar to certify_bootimg(), but for a zip archive of boot images."""
+    with tempfile.TemporaryDirectory() as unzip_dir:
+        shutil.unpack_archive(boot_img_zip, unzip_dir)
+
+        gki_info_file = os.path.join(unzip_dir, 'gki-info.txt')
+        if os.path.exists(gki_info_file):
+            info_dict = load_dict_from_file(gki_info_file)
+            if 'certify_bootimg_extra_args' in info_dict:
+                extra_args.extend(
+                    shlex.split(info_dict['certify_bootimg_extra_args']))
+
+        for boot_img in glob.glob(os.path.join(unzip_dir, 'boot-*.img')):
+            print(f'Certifying {os.path.basename(boot_img)} ...')
+            certify_bootimg(boot_img=boot_img, output_img=boot_img,
+                            algorithm=algorithm, key=key, extra_args=extra_args)
+
+        print(f'Making certified archive: {output_zip}')
+        archive_base_name = os.path.splitext(output_zip)[0]
+        shutil.make_archive(archive_base_name, 'zip', unzip_dir)
+
+
 def main():
     """Parse arguments and certify the boot image."""
     args = parse_cmdline()
 
-    shutil.copy2(args.boot_img, args.output)
-    erase_certificate_and_avb_footer(args.output)
-
-    add_certificate(args.output, args.algorithm, args.key, args.extra_args)
-
-    avb_partition_size = get_avb_image_size(args.boot_img)
-    add_avb_footer(args.output, avb_partition_size)
+    if args.boot_img_zip:
+        certify_bootimg_zip(args.boot_img_zip, args.output, args.algorithm,
+                            args.key, args.extra_args)
+    else:
+        certify_bootimg(args.boot_img, args.output, args.algorithm,
+                        args.key, args.extra_args)
 
 
 if __name__ == '__main__':
diff --git a/gki/certify_bootimg_test.py b/gki/certify_bootimg_test.py
index 25cdbff..8c7c4d3 100644
--- a/gki/certify_bootimg_test.py
+++ b/gki/certify_bootimg_test.py
@@ -17,6 +17,7 @@
 """Tests certify_bootimg."""
 
 import logging
+import glob
 import os
 import random
 import shutil
@@ -42,11 +43,11 @@
     return pathname
 
 
-def generate_test_boot_image(boot_img, avb_partition_size=None):
+def generate_test_boot_image(boot_img, kernel_size=4096, seed='kernel',
+                             avb_partition_size=None):
     """Generates a test boot.img without a ramdisk."""
     with tempfile.NamedTemporaryFile() as kernel_tmpfile:
-        generate_test_file(pathname=kernel_tmpfile.name, size=0x1000,
-                           seed='kernel')
+        generate_test_file(kernel_tmpfile.name, kernel_size, seed)
         kernel_tmpfile.flush()
 
         mkbootimg_cmds = [
@@ -67,6 +68,38 @@
         subprocess.check_call(avbtool_cmd)
 
 
+def generate_test_boot_image_archive(output_zip, boot_img_info, gki_info=None):
+    """Generates a zip archive of test boot images.
+
+    It also adds a file gki-info.txt, which contains additional settings for
+    for `certify_bootimg --extra_args`.
+
+    Args:
+        output_zip: the output zip archive, e.g., /path/to/boot-img.zip.
+        boot_img_info: a list of (boot_image_name, kernel_size,
+          partition_size) tuples. e.g.,
+          [('boot-1.0.img', 4096, 4 * 1024),
+           ('boot-2.0.img', 8192, 8 * 1024)].
+        gki_info: the file content to be written into 'gki-info.txt' in the
+          |output_zip|.
+    """
+    with tempfile.TemporaryDirectory() as temp_out_dir:
+        for name, kernel_size, partition_size in boot_img_info:
+            boot_img = os.path.join(temp_out_dir, name)
+            generate_test_boot_image(boot_img=boot_img,
+                                     kernel_size=kernel_size,
+                                     seed=name,
+                                     avb_partition_size=partition_size)
+
+        if gki_info:
+            gki_info_path = os.path.join(temp_out_dir, 'gki-info.txt')
+            with open(gki_info_path, 'w', encoding='utf-8') as f:
+                f.write(gki_info)
+
+        archive_base_name = os.path.splitext(output_zip)[0]
+        shutil.make_archive(archive_base_name, 'zip', temp_out_dir)
+
+
 def has_avb_footer(image):
     """Returns true if the image has a avb footer."""
 
@@ -105,7 +138,12 @@
 
 
 def extract_boot_signatures(boot_img, output_dir):
-    """Extracts the boot signatures of a boot image."""
+    """Extracts the boot signatures of a boot image.
+
+    This functions extracts the boot signatures of |boot_img| as:
+      - |output_dir|/boot_signature1
+      - |output_dir|/boot_signature2
+    """
 
     boot_img_copy = os.path.join(output_dir, 'boot_image_copy')
     shutil.copy2(boot_img, boot_img_copy)
@@ -137,6 +175,27 @@
         boot_signature_bytes = boot_signature_bytes[next_signature_size:]
 
 
+def extract_boot_archive_with_signatures(boot_img_zip, output_dir):
+    """Extracts boot images and signatures of a boot images archive.
+
+    Suppose there are two boot images in |boot_img_zip|: boot-1.0.img
+    and boot-2.0.img. This function then extracts each boot-*.img and
+    their signatures as:
+      - |output_dir|/boot-1.0.img
+      - |output_dir|/boot-2.0.img
+      - |output_dir|/boot-1.0/boot_signature1
+      - |output_dir|/boot-1.0/boot_signature2
+      - |output_dir|/boot-2.0/boot_signature1
+      - |output_dir|/boot-2.0/boot_signature2
+    """
+    shutil.unpack_archive(boot_img_zip, output_dir)
+    for boot_img in glob.glob(os.path.join(output_dir, 'boot-*.img')):
+        img_name = os.path.splitext(os.path.basename(boot_img))[0]
+        signature_output_dir = os.path.join(output_dir, img_name)
+        os.mkdir(signature_output_dir, 0o777)
+        extract_boot_signatures(boot_img, signature_output_dir)
+
+
 class CertifyBootimgTest(unittest.TestCase):
     """Tests the functionalities of certify_bootimg."""
 
@@ -174,8 +233,8 @@
             'faf1da72a4fba97ddab0b8f7a410db86'
             '8fb72392a66d1440ff8bff490c73c771\n'
             '      Flags:                 0\n'
-            "    Prop: foo -> 'bar'\n"
             "    Prop: gki -> 'nice'\n"
+            "    Prop: space -> 'nice to meet you'\n"
         )
 
         self._EXPECTED_KERNEL_SIGNATURE_RSA2048 = (     # pylint: disable=C0103
@@ -200,8 +259,8 @@
             '762c877f3af0d50a4a4fbc1385d5c7ce'
             '52a1288db74b33b72217d93db6f2909f\n'
             '      Flags:                 0\n'
-            "    Prop: foo -> 'bar'\n"
             "    Prop: gki -> 'nice'\n"
+            "    Prop: space -> 'nice to meet you'\n"
         )
 
         self._EXPECTED_BOOT_SIGNATURE_RSA4096 = (       # pylint: disable=C0103
@@ -226,8 +285,8 @@
             'faf1da72a4fba97ddab0b8f7a410db86'
             '8fb72392a66d1440ff8bff490c73c771\n'
             '      Flags:                 0\n'
-            "    Prop: foo -> 'bar'\n"
             "    Prop: gki -> 'nice'\n"
+            "    Prop: space -> 'nice to meet you'\n"
         )
 
         self._EXPECTED_KERNEL_SIGNATURE_RSA4096 = (     # pylint: disable=C0103
@@ -252,8 +311,184 @@
             '762c877f3af0d50a4a4fbc1385d5c7ce'
             '52a1288db74b33b72217d93db6f2909f\n'
             '      Flags:                 0\n'
-            "    Prop: foo -> 'bar'\n"
             "    Prop: gki -> 'nice'\n"
+            "    Prop: space -> 'nice to meet you'\n"
+        )
+
+        self._EXPECTED_BOOT_1_0_SIGNATURE1_RSA4096 = (   # pylint: disable=C0103
+            'Minimum libavb version:   1.0\n'
+            'Header Block:             256 bytes\n'
+            'Authentication Block:     576 bytes\n'
+            'Auxiliary Block:          1600 bytes\n'
+            'Public key (sha1):        '
+            '2597c218aae470a130f61162feaae70afd97f011\n'
+            'Algorithm:                SHA256_RSA4096\n'    # RSA4096
+            'Rollback Index:           0\n'
+            'Flags:                    0\n'
+            'Rollback Index Location:  0\n'
+            "Release String:           'avbtool 1.2.0'\n"
+            'Descriptors:\n'
+            '    Hash descriptor:\n'
+            '      Image Size:            12288 bytes\n'
+            '      Hash Algorithm:        sha256\n'
+            '      Partition Name:        boot\n'           # boot
+            '      Salt:                  d00df00d\n'
+            '      Digest:                '
+            '88465e463bffb9f7dfc0c1f46d01bcf3'
+            '15f7693e19bd188a0ca1feca2ed7b9df\n'
+            '      Flags:                 0\n'
+            "    Prop: gki -> 'nice'\n"
+            "    Prop: space -> 'nice to meet you'\n"
+            "    Prop: KERNEL_RELEASE -> '5.10.42-android13-0-00544-"
+            "ged21d463f856'\n"
+            "    Prop: BRANCH -> 'android13-5.10-2022-05'\n"
+            "    Prop: BUILD_NUMBER -> 'ab8295296'\n"
+            "    Prop: SPACE -> 'nice to meet you'\n"
+        )
+
+        self._EXPECTED_BOOT_1_0_SIGNATURE2_RSA4096 = (   # pylint: disable=C0103
+            'Minimum libavb version:   1.0\n'
+            'Header Block:             256 bytes\n'
+            'Authentication Block:     576 bytes\n'
+            'Auxiliary Block:          1600 bytes\n'
+            'Public key (sha1):        '
+            '2597c218aae470a130f61162feaae70afd97f011\n'
+            'Algorithm:                SHA256_RSA4096\n'    # RSA4096
+            'Rollback Index:           0\n'
+            'Flags:                    0\n'
+            'Rollback Index Location:  0\n'
+            "Release String:           'avbtool 1.2.0'\n"
+            'Descriptors:\n'
+            '    Hash descriptor:\n'
+            '      Image Size:            8192 bytes\n'
+            '      Hash Algorithm:        sha256\n'
+            '      Partition Name:        generic_kernel\n' # generic_kernel
+            '      Salt:                  d00df00d\n'
+            '      Digest:                '
+            '14ac8d0d233e57a317acd05cd458f2bb'
+            'cc78725ef9f66c1b38e90697fb09d943\n'
+            '      Flags:                 0\n'
+            "    Prop: gki -> 'nice'\n"
+            "    Prop: space -> 'nice to meet you'\n"
+            "    Prop: KERNEL_RELEASE -> '5.10.42-android13-0-00544-"
+            "ged21d463f856'\n"
+            "    Prop: BRANCH -> 'android13-5.10-2022-05'\n"
+            "    Prop: BUILD_NUMBER -> 'ab8295296'\n"
+            "    Prop: SPACE -> 'nice to meet you'\n"
+        )
+
+        self._EXPECTED_BOOT_2_0_SIGNATURE1_RSA4096 = (   # pylint: disable=C0103
+            'Minimum libavb version:   1.0\n'
+            'Header Block:             256 bytes\n'
+            'Authentication Block:     576 bytes\n'
+            'Auxiliary Block:          1600 bytes\n'
+            'Public key (sha1):        '
+            '2597c218aae470a130f61162feaae70afd97f011\n'
+            'Algorithm:                SHA256_RSA4096\n'    # RSA4096
+            'Rollback Index:           0\n'
+            'Flags:                    0\n'
+            'Rollback Index Location:  0\n'
+            "Release String:           'avbtool 1.2.0'\n"
+            'Descriptors:\n'
+            '    Hash descriptor:\n'
+            '      Image Size:            20480 bytes\n'
+            '      Hash Algorithm:        sha256\n'
+            '      Partition Name:        boot\n'           # boot
+            '      Salt:                  d00df00d\n'
+            '      Digest:                '
+            '3e6a9854a9d2350a7071083bc3f37376'
+            '37573fd87b1c72b146cb4870ac6af36f\n'
+            '      Flags:                 0\n'
+            "    Prop: gki -> 'nice'\n"
+            "    Prop: space -> 'nice to meet you'\n"
+            "    Prop: KERNEL_RELEASE -> '5.10.42-android13-0-00544-"
+            "ged21d463f856'\n"
+            "    Prop: BRANCH -> 'android13-5.10-2022-05'\n"
+            "    Prop: BUILD_NUMBER -> 'ab8295296'\n"
+            "    Prop: SPACE -> 'nice to meet you'\n"
+        )
+
+        self._EXPECTED_BOOT_2_0_SIGNATURE2_RSA4096 = (   # pylint: disable=C0103
+            'Minimum libavb version:   1.0\n'
+            'Header Block:             256 bytes\n'
+            'Authentication Block:     576 bytes\n'
+            'Auxiliary Block:          1600 bytes\n'
+            'Public key (sha1):        '
+            '2597c218aae470a130f61162feaae70afd97f011\n'
+            'Algorithm:                SHA256_RSA4096\n'    # RSA4096
+            'Rollback Index:           0\n'
+            'Flags:                    0\n'
+            'Rollback Index Location:  0\n'
+            "Release String:           'avbtool 1.2.0'\n"
+            'Descriptors:\n'
+            '    Hash descriptor:\n'
+            '      Image Size:            16384 bytes\n'
+            '      Hash Algorithm:        sha256\n'
+            '      Partition Name:        generic_kernel\n' # generic_kernel
+            '      Salt:                  d00df00d\n'
+            '      Digest:                '
+            '92fb8443cd284b67a4cbf5ce00348b50'
+            '1c657e0aedf4e2181c92ad7fc8b5224f\n'
+            '      Flags:                 0\n'
+            "    Prop: gki -> 'nice'\n"
+            "    Prop: space -> 'nice to meet you'\n"
+            "    Prop: KERNEL_RELEASE -> '5.10.42-android13-0-00544-"
+            "ged21d463f856'\n"
+            "    Prop: BRANCH -> 'android13-5.10-2022-05'\n"
+            "    Prop: BUILD_NUMBER -> 'ab8295296'\n"
+            "    Prop: SPACE -> 'nice to meet you'\n"
+        )
+
+        self._EXPECTED_BOOT_3_0_SIGNATURE1_RSA4096 = (   # pylint: disable=C0103
+            'Minimum libavb version:   1.0\n'
+            'Header Block:             256 bytes\n'
+            'Authentication Block:     576 bytes\n'
+            'Auxiliary Block:          1344 bytes\n'
+            'Public key (sha1):        '
+            '2597c218aae470a130f61162feaae70afd97f011\n'
+            'Algorithm:                SHA256_RSA4096\n'    # RSA4096
+            'Rollback Index:           0\n'
+            'Flags:                    0\n'
+            'Rollback Index Location:  0\n'
+            "Release String:           'avbtool 1.2.0'\n"
+            'Descriptors:\n'
+            '    Hash descriptor:\n'
+            '      Image Size:            12288 bytes\n'
+            '      Hash Algorithm:        sha256\n'
+            '      Partition Name:        boot\n'           # boot
+            '      Salt:                  d00df00d\n'
+            '      Digest:                '
+            '9b9cd845a367d7fc9b61d6ac02b0e7c9'
+            'dc3d3b219abf60dd6e19359f0353c917\n'
+            '      Flags:                 0\n'
+            "    Prop: gki -> 'nice'\n"
+            "    Prop: space -> 'nice to meet you'\n"
+        )
+
+        self._EXPECTED_BOOT_3_0_SIGNATURE2_RSA4096 = (   # pylint: disable=C0103
+            'Minimum libavb version:   1.0\n'
+            'Header Block:             256 bytes\n'
+            'Authentication Block:     576 bytes\n'
+            'Auxiliary Block:          1344 bytes\n'
+            'Public key (sha1):        '
+            '2597c218aae470a130f61162feaae70afd97f011\n'
+            'Algorithm:                SHA256_RSA4096\n'    # RSA4096
+            'Rollback Index:           0\n'
+            'Flags:                    0\n'
+            'Rollback Index Location:  0\n'
+            "Release String:           'avbtool 1.2.0'\n"
+            'Descriptors:\n'
+            '    Hash descriptor:\n'
+            '      Image Size:            8192 bytes\n'
+            '      Hash Algorithm:        sha256\n'
+            '      Partition Name:        generic_kernel\n' # generic_kernel
+            '      Salt:                  d00df00d\n'
+            '      Digest:                '
+            '0cd7d331ed9b32dcd92f00e2cac75595'
+            '52199170afe788a8fcf1954f9ea072d0\n'
+            '      Flags:                 0\n'
+            "    Prop: gki -> 'nice'\n"
+            "    Prop: space -> 'nice to meet you'\n"
         )
 
     def _test_boot_signatures(self, signatures_dir, expected_signatures_info):
@@ -292,7 +527,8 @@
                 '--boot_img', boot_img,
                 '--algorithm', 'SHA256_RSA2048',
                 '--key', './testdata/testkey_rsa2048.pem',
-                '--extra_args', '--prop foo:bar --prop gki:nice',
+                '--extra_args', '--prop gki:nice '
+                '--prop space:"nice to meet you"',
                 '--output', boot_certified_img,
             ]
             subprocess.run(certify_bootimg_cmds, check=True, cwd=self._exec_dir)
@@ -311,7 +547,8 @@
                 '--boot_img', boot_certified_img,
                 '--algorithm', 'SHA256_RSA4096',
                 '--key', './testdata/testkey_rsa4096.pem',
-                '--extra_args', '--prop foo:bar --prop gki:nice',
+                '--extra_args', '--prop gki:nice '
+                '--prop space:"nice to meet you"',
                 '--output', boot_certified2_img,
             ]
             subprocess.run(certify_bootimg_cmds, check=True, cwd=self._exec_dir)
@@ -338,7 +575,8 @@
                 '--boot_img', boot_img,
                 '--algorithm', 'SHA256_RSA2048',
                 '--key', './testdata/testkey_rsa2048.pem',
-                '--extra_args', '--prop foo:bar --prop gki:nice',
+                '--extra_args', '--prop gki:nice '
+                '--prop space:"nice to meet you"',
                 '--output', boot_certified_img,
             ]
             subprocess.run(certify_bootimg_cmds, check=True, cwd=self._exec_dir)
@@ -362,7 +600,8 @@
                 '--boot_img', boot_certified_img,
                 '--algorithm', 'SHA256_RSA4096',
                 '--key', './testdata/testkey_rsa4096.pem',
-                '--extra_args', '--prop foo:bar --prop gki:nice',
+                '--extra_args', '--prop gki:nice '
+                '--prop space:"nice to meet you"',
                 '--output', boot_certified2_img,
             ]
             subprocess.run(certify_bootimg_cmds, check=True, cwd=self._exec_dir)
@@ -407,6 +646,123 @@
                 self.assertIn('ValueError: boot_signature size must be <= ',
                               err.stderr)
 
+    def test_certify_bootimg_archive(self):
+        """Tests certify_bootimg for a boot-img.zip."""
+        with tempfile.TemporaryDirectory() as temp_out_dir:
+            boot_img_zip = os.path.join(temp_out_dir, 'boot-img.zip')
+            gki_info = ('certify_bootimg_extra_args='
+                        '--prop KERNEL_RELEASE:5.10.42'
+                        '-android13-0-00544-ged21d463f856 '
+                        '--prop BRANCH:android13-5.10-2022-05 '
+                        '--prop BUILD_NUMBER:ab8295296 '
+                        '--prop SPACE:"nice to meet you"\n')
+            generate_test_boot_image_archive(
+                boot_img_zip,
+                # A list of (boot_img_name, kernel_size, partition_size).
+                [('boot-1.0.img', 8 * 1024, 128 * 1024),
+                 ('boot-2.0.img', 16 * 1024, 256 * 1024)],
+                gki_info)
+
+            # Certify the boot image archive, with a RSA4096 key.
+            boot_certified_img_zip = os.path.join(temp_out_dir,
+                                                  'boot-certified-img.zip')
+            certify_bootimg_cmds = [
+                'certify_bootimg',
+                '--boot_img_zip', boot_img_zip,
+                '--algorithm', 'SHA256_RSA4096',
+                '--key', './testdata/testkey_rsa4096.pem',
+                '--extra_args', '--prop gki:nice '
+                '--prop space:"nice to meet you"',
+                '--output', boot_certified_img_zip,
+            ]
+            subprocess.run(certify_bootimg_cmds, check=True, cwd=self._exec_dir)
+
+            extract_boot_archive_with_signatures(boot_certified_img_zip,
+                                                 temp_out_dir)
+
+            # Checks an AVB footer exists and the image size remains.
+            boot_1_img = os.path.join(temp_out_dir, 'boot-1.0.img')
+            self.assertTrue(has_avb_footer(boot_1_img))
+            self.assertEqual(os.path.getsize(boot_1_img), 128 * 1024)
+
+            boot_2_img = os.path.join(temp_out_dir, 'boot-2.0.img')
+            self.assertTrue(has_avb_footer(boot_2_img))
+            self.assertEqual(os.path.getsize(boot_2_img), 256 * 1024)
+
+            self._test_boot_signatures(
+                temp_out_dir,
+                {'boot-1.0/boot_signature1':
+                    self._EXPECTED_BOOT_1_0_SIGNATURE1_RSA4096,
+                 'boot-1.0/boot_signature2':
+                    self._EXPECTED_BOOT_1_0_SIGNATURE2_RSA4096,
+                 'boot-2.0/boot_signature1':
+                    self._EXPECTED_BOOT_2_0_SIGNATURE1_RSA4096,
+                 'boot-2.0/boot_signature2':
+                    self._EXPECTED_BOOT_2_0_SIGNATURE2_RSA4096})
+
+    def test_certify_bootimg_archive_without_gki_info(self):
+        """Tests certify_bootimg for a boot-img.zip."""
+        with tempfile.TemporaryDirectory() as temp_out_dir:
+            boot_img_zip = os.path.join(temp_out_dir, 'boot-img.zip')
+
+            # Checks ceritfy_bootimg works for a boot-img.zip without a
+            # gki-info.txt.
+            generate_test_boot_image_archive(
+                boot_img_zip,
+                # A list of (boot_img_name, kernel_size, partition_size).
+                [('boot-3.0.img', 8 * 1024, 128 * 1024)],
+                gki_info=None)
+            # Certify the boot image archive, with a RSA4096 key.
+            boot_certified_img_zip = os.path.join(temp_out_dir,
+                                                  'boot-certified-img.zip')
+            certify_bootimg_cmds = [
+                'certify_bootimg',
+                '--boot_img_zip', boot_img_zip,
+                '--algorithm', 'SHA256_RSA4096',
+                '--key', './testdata/testkey_rsa4096.pem',
+                '--extra_args', '--prop gki:nice '
+                '--prop space:"nice to meet you"',
+                '--output', boot_certified_img_zip,
+            ]
+            subprocess.run(certify_bootimg_cmds, check=True, cwd=self._exec_dir)
+
+            # Checks ceritfy_bootimg works for a boot-img.zip with a special
+            # gki-info.txt.
+            generate_test_boot_image_archive(
+                boot_img_zip,
+                # A list of (boot_img_name, kernel_size, partition_size).
+                [('boot-3.0.img', 8 * 1024, 128 * 1024)],
+                gki_info='a=b\n'
+                         'c=d\n')
+            # Certify the boot image archive, with a RSA4096 key.
+            boot_certified_img_zip = os.path.join(temp_out_dir,
+                                                  'boot-certified-img.zip')
+            certify_bootimg_cmds = [
+                'certify_bootimg',
+                '--boot_img_zip', boot_img_zip,
+                '--algorithm', 'SHA256_RSA4096',
+                '--key', './testdata/testkey_rsa4096.pem',
+                '--extra_args', '--prop gki:nice '
+                '--prop space:"nice to meet you"',
+                '--output', boot_certified_img_zip,
+            ]
+            subprocess.run(certify_bootimg_cmds, check=True, cwd=self._exec_dir)
+
+            extract_boot_archive_with_signatures(boot_certified_img_zip,
+                                                 temp_out_dir)
+
+            # Checks an AVB footer exists and the image size remains.
+            boot_3_img = os.path.join(temp_out_dir, 'boot-3.0.img')
+            self.assertTrue(has_avb_footer(boot_3_img))
+            self.assertEqual(os.path.getsize(boot_3_img), 128 * 1024)
+
+            self._test_boot_signatures(
+                temp_out_dir,
+                {'boot-3.0/boot_signature1':
+                    self._EXPECTED_BOOT_3_0_SIGNATURE1_RSA4096,
+                 'boot-3.0/boot_signature2':
+                    self._EXPECTED_BOOT_3_0_SIGNATURE2_RSA4096})
+
 
 # I don't know how, but we need both the logger configuration and verbosity
 # level > 2 to make atest work. And yes this line needs to be at the very top
diff --git a/gki/generate_gki_certificate.py b/gki/generate_gki_certificate.py
index 0765c08..2797cca 100755
--- a/gki/generate_gki_certificate.py
+++ b/gki/generate_gki_certificate.py
@@ -18,6 +18,7 @@
 """Generate a Generic Boot Image certificate suitable for VTS verification."""
 
 from argparse import ArgumentParser
+import shlex
 import subprocess
 
 
@@ -73,7 +74,7 @@
 
     additional_avb_args = []
     for a in args.additional_avb_args:
-        additional_avb_args.extend(a.split())
+        additional_avb_args.extend(shlex.split(a))
     args.additional_avb_args = additional_avb_args
 
     return args
diff --git a/mkbootimg.py b/mkbootimg.py
index 5c65e2c..ec29581 100755
--- a/mkbootimg.py
+++ b/mkbootimg.py
@@ -141,9 +141,6 @@
 
 
 def write_vendor_boot_header(args):
-    if filesize(args.dtb) == 0:
-        raise ValueError('DTB image must not be empty.')
-
     if args.header_version > 3:
         vendor_ramdisk_size = args.vendor_ramdisk_total_size
         vendor_boot_header_size = VENDOR_BOOT_IMAGE_HEADER_V4_SIZE
diff --git a/tests/mkbootimg_test.py b/tests/mkbootimg_test.py
index 28f47f0..e691e30 100644
--- a/tests/mkbootimg_test.py
+++ b/tests/mkbootimg_test.py
@@ -760,6 +760,80 @@
             self.assertEqual(raw_vendor_cmdline,
                              vendor_cmdline.encode() + b'\x00')
 
+    def test_vendor_boot_v4_without_dtb(self):
+        """Tests building vendor_boot version 4 without dtb image."""
+        with tempfile.TemporaryDirectory() as temp_out_dir:
+            vendor_boot_img = os.path.join(temp_out_dir, 'vendor_boot.img')
+            ramdisk = generate_test_file(
+                os.path.join(temp_out_dir, 'ramdisk'), 0x1000)
+            mkbootimg_cmds = [
+                'mkbootimg',
+                '--header_version', '4',
+                '--vendor_boot', vendor_boot_img,
+                '--vendor_ramdisk', ramdisk,
+            ]
+            unpack_bootimg_cmds = [
+                'unpack_bootimg',
+                '--boot_img', vendor_boot_img,
+                '--out', os.path.join(temp_out_dir, 'out'),
+            ]
+            expected_output = [
+                'boot magic: VNDRBOOT',
+                'vendor boot image header version: 4',
+                'dtb size: 0',
+            ]
+
+            subprocess.run(mkbootimg_cmds, check=True)
+            result = subprocess.run(unpack_bootimg_cmds, check=True,
+                                    capture_output=True, encoding='utf-8')
+            output = [line.strip() for line in result.stdout.splitlines()]
+            if not subsequence_of(expected_output, output):
+                msg = '\n'.join([
+                    'Unexpected unpack_bootimg output:',
+                    'Expected:',
+                    ' ' + '\n '.join(expected_output),
+                    '',
+                    'Actual:',
+                    ' ' + '\n '.join(output),
+                ])
+                self.fail(msg)
+
+    def test_unpack_vendor_boot_image_v4_without_dtb(self):
+        """Tests that mkbootimg(unpack_bootimg(image)) is an identity when no dtb image."""
+        with tempfile.TemporaryDirectory() as temp_out_dir:
+            vendor_boot_img = os.path.join(temp_out_dir, 'vendor_boot.img')
+            vendor_boot_img_reconstructed = os.path.join(
+                temp_out_dir, 'vendor_boot.img.reconstructed')
+            ramdisk = generate_test_file(
+                os.path.join(temp_out_dir, 'ramdisk'), 0x121212)
+
+            mkbootimg_cmds = [
+                'mkbootimg',
+                '--header_version', '4',
+                '--vendor_boot', vendor_boot_img,
+                '--vendor_ramdisk', ramdisk,
+            ]
+            unpack_bootimg_cmds = [
+                'unpack_bootimg',
+                '--boot_img', vendor_boot_img,
+                '--out', os.path.join(temp_out_dir, 'out'),
+                '--format=mkbootimg',
+            ]
+            subprocess.run(mkbootimg_cmds, check=True)
+            result = subprocess.run(unpack_bootimg_cmds, check=True,
+                                    capture_output=True, encoding='utf-8')
+            mkbootimg_cmds = [
+                'mkbootimg',
+                '--vendor_boot', vendor_boot_img_reconstructed,
+            ]
+            unpack_format_args = shlex.split(result.stdout)
+            mkbootimg_cmds.extend(unpack_format_args)
+
+            subprocess.run(mkbootimg_cmds, check=True)
+            self.assertTrue(
+                filecmp.cmp(vendor_boot_img, vendor_boot_img_reconstructed),
+                'reconstructed vendor_boot image differ from the original')
+
 
 # I don't know how, but we need both the logger configuration and verbosity
 # level > 2 to make atest work. And yes this line needs to be at the very top
diff --git a/unpack_bootimg.py b/unpack_bootimg.py
index 437408f..462190f 100755
--- a/unpack_bootimg.py
+++ b/unpack_bootimg.py
@@ -19,7 +19,7 @@
 Extracts the kernel, ramdisk, second bootloader, dtb and recovery dtbo images.
 """
 
-from argparse import ArgumentParser, FileType, RawDescriptionHelpFormatter
+from argparse import ArgumentParser, RawDescriptionHelpFormatter
 from struct import unpack
 import os
 import shlex
@@ -58,7 +58,7 @@
     a = os_version >> 14
     b = os_version >> 7 & ((1<<7) - 1)
     c = os_version & ((1<<7) - 1)
-    return '{}.{}.{}'.format(a, b, c)
+    return f'{a}.{b}.{c}'
 
 
 def format_os_patch_level(os_patch_level):
@@ -67,7 +67,7 @@
     y = os_patch_level >> 4
     y += 2000
     m = os_patch_level & ((1<<4) - 1)
-    return '{:04d}-{:02d}'.format(y, m)
+    return f'{y:04d}-{m:02d}'
 
 
 def decode_os_version_patch_level(os_version_patch_level):
@@ -181,12 +181,12 @@
         return args
 
 
-def unpack_boot_image(args):
+def unpack_boot_image(boot_img, output_dir):
     """extracts kernel, ramdisk, second bootloader and recovery dtbo"""
     info = BootImageInfoFormatter()
-    info.boot_magic = unpack('8s', args.boot_img.read(8))[0].decode()
+    info.boot_magic = unpack('8s', boot_img.read(8))[0].decode()
 
-    kernel_ramdisk_second_info = unpack('9I', args.boot_img.read(9 * 4))
+    kernel_ramdisk_second_info = unpack('9I', boot_img.read(9 * 4))
     # header_version is always at [8] regardless of the value of header_version.
     info.header_version = kernel_ramdisk_second_info[8]
 
@@ -199,7 +199,7 @@
         info.second_load_address = kernel_ramdisk_second_info[5]
         info.tags_load_address = kernel_ramdisk_second_info[6]
         info.page_size = kernel_ramdisk_second_info[7]
-        os_version_patch_level = unpack('I', args.boot_img.read(1 * 4))[0]
+        os_version_patch_level = unpack('I', boot_img.read(1 * 4))[0]
     else:
         info.kernel_size = kernel_ramdisk_second_info[0]
         info.ramdisk_size = kernel_ramdisk_second_info[1]
@@ -212,31 +212,31 @@
 
     if info.header_version < 3:
         info.product_name = cstr(unpack('16s',
-                                        args.boot_img.read(16))[0].decode())
-        info.cmdline = cstr(unpack('512s', args.boot_img.read(512))[0].decode())
-        args.boot_img.read(32)  # ignore SHA
+                                        boot_img.read(16))[0].decode())
+        info.cmdline = cstr(unpack('512s', boot_img.read(512))[0].decode())
+        boot_img.read(32)  # ignore SHA
         info.extra_cmdline = cstr(unpack('1024s',
-                                         args.boot_img.read(1024))[0].decode())
+                                         boot_img.read(1024))[0].decode())
     else:
         info.cmdline = cstr(unpack('1536s',
-                                   args.boot_img.read(1536))[0].decode())
+                                   boot_img.read(1536))[0].decode())
 
     if info.header_version in {1, 2}:
-        info.recovery_dtbo_size = unpack('I', args.boot_img.read(1 * 4))[0]
-        info.recovery_dtbo_offset = unpack('Q', args.boot_img.read(8))[0]
-        info.boot_header_size = unpack('I', args.boot_img.read(4))[0]
+        info.recovery_dtbo_size = unpack('I', boot_img.read(1 * 4))[0]
+        info.recovery_dtbo_offset = unpack('Q', boot_img.read(8))[0]
+        info.boot_header_size = unpack('I', boot_img.read(4))[0]
     else:
         info.recovery_dtbo_size = 0
 
     if info.header_version == 2:
-        info.dtb_size = unpack('I', args.boot_img.read(4))[0]
-        info.dtb_load_address = unpack('Q', args.boot_img.read(8))[0]
+        info.dtb_size = unpack('I', boot_img.read(4))[0]
+        info.dtb_load_address = unpack('Q', boot_img.read(8))[0]
     else:
         info.dtb_size = 0
         info.dtb_load_address = 0
 
     if info.header_version >= 4:
-        info.boot_signature_size = unpack('I', args.boot_img.read(4))[0]
+        info.boot_signature_size = unpack('I', boot_img.read(4))[0]
     else:
         info.boot_signature_size = 0
 
@@ -284,10 +284,10 @@
         image_info_list.append((boot_signature_offset, info.boot_signature_size,
                                 'boot_signature'))
 
-    create_out_dir(args.out)
+    create_out_dir(output_dir)
     for offset, size, name in image_info_list:
-        extract_image(offset, size, args.boot_img, os.path.join(args.out, name))
-    info.image_dir = args.out
+        extract_image(offset, size, boot_img, os.path.join(output_dir, name))
+    info.image_dir = output_dir
 
     return info
 
@@ -353,7 +353,8 @@
         args.extend(['--vendor_cmdline', self.cmdline])
         args.extend(['--board', self.product_name])
 
-        args.extend(['--dtb', os.path.join(self.image_dir, 'dtb')])
+        if self.dtb_size > 0:
+            args.extend(['--dtb', os.path.join(self.image_dir, 'dtb')])
 
         if self.header_version > 3:
             args.extend(['--vendor_bootconfig',
@@ -377,20 +378,20 @@
         return args
 
 
-def unpack_vendor_boot_image(args):
+def unpack_vendor_boot_image(boot_img, output_dir):
     info = VendorBootImageInfoFormatter()
-    info.boot_magic = unpack('8s', args.boot_img.read(8))[0].decode()
-    info.header_version = unpack('I', args.boot_img.read(4))[0]
-    info.page_size = unpack('I', args.boot_img.read(4))[0]
-    info.kernel_load_address = unpack('I', args.boot_img.read(4))[0]
-    info.ramdisk_load_address = unpack('I', args.boot_img.read(4))[0]
-    info.vendor_ramdisk_size = unpack('I', args.boot_img.read(4))[0]
-    info.cmdline = cstr(unpack('2048s', args.boot_img.read(2048))[0].decode())
-    info.tags_load_address = unpack('I', args.boot_img.read(4))[0]
-    info.product_name = cstr(unpack('16s', args.boot_img.read(16))[0].decode())
-    info.header_size = unpack('I', args.boot_img.read(4))[0]
-    info.dtb_size = unpack('I', args.boot_img.read(4))[0]
-    info.dtb_load_address = unpack('Q', args.boot_img.read(8))[0]
+    info.boot_magic = unpack('8s', boot_img.read(8))[0].decode()
+    info.header_version = unpack('I', boot_img.read(4))[0]
+    info.page_size = unpack('I', boot_img.read(4))[0]
+    info.kernel_load_address = unpack('I', boot_img.read(4))[0]
+    info.ramdisk_load_address = unpack('I', boot_img.read(4))[0]
+    info.vendor_ramdisk_size = unpack('I', boot_img.read(4))[0]
+    info.cmdline = cstr(unpack('2048s', boot_img.read(2048))[0].decode())
+    info.tags_load_address = unpack('I', boot_img.read(4))[0]
+    info.product_name = cstr(unpack('16s', boot_img.read(16))[0].decode())
+    info.header_size = unpack('I', boot_img.read(4))[0]
+    info.dtb_size = unpack('I', boot_img.read(4))[0]
+    info.dtb_load_address = unpack('Q', boot_img.read(8))[0]
 
     # Convenient shorthand.
     page_size = info.page_size
@@ -407,10 +408,10 @@
         (ramdisk_offset_base, info.vendor_ramdisk_size, 'vendor_ramdisk'))
 
     if info.header_version > 3:
-        info.vendor_ramdisk_table_size = unpack('I', args.boot_img.read(4))[0]
-        vendor_ramdisk_table_entry_num = unpack('I', args.boot_img.read(4))[0]
-        vendor_ramdisk_table_entry_size = unpack('I', args.boot_img.read(4))[0]
-        info.vendor_bootconfig_size = unpack('I', args.boot_img.read(4))[0]
+        info.vendor_ramdisk_table_size = unpack('I', boot_img.read(4))[0]
+        vendor_ramdisk_table_entry_num = unpack('I', boot_img.read(4))[0]
+        vendor_ramdisk_table_entry_size = unpack('I', boot_img.read(4))[0]
+        info.vendor_bootconfig_size = unpack('I', boot_img.read(4))[0]
         num_vendor_ramdisk_table_pages = get_number_of_pages(
             info.vendor_ramdisk_table_size, page_size)
         vendor_ramdisk_table_offset = page_size * (
@@ -421,16 +422,16 @@
         for idx in range(vendor_ramdisk_table_entry_num):
             entry_offset = vendor_ramdisk_table_offset + (
                 vendor_ramdisk_table_entry_size * idx)
-            args.boot_img.seek(entry_offset)
-            ramdisk_size = unpack('I', args.boot_img.read(4))[0]
-            ramdisk_offset = unpack('I', args.boot_img.read(4))[0]
-            ramdisk_type = unpack('I', args.boot_img.read(4))[0]
+            boot_img.seek(entry_offset)
+            ramdisk_size = unpack('I', boot_img.read(4))[0]
+            ramdisk_offset = unpack('I', boot_img.read(4))[0]
+            ramdisk_type = unpack('I', boot_img.read(4))[0]
             ramdisk_name = cstr(unpack(
                 f'{VENDOR_RAMDISK_NAME_SIZE}s',
-                args.boot_img.read(VENDOR_RAMDISK_NAME_SIZE))[0].decode())
+                boot_img.read(VENDOR_RAMDISK_NAME_SIZE))[0].decode())
             board_id = unpack(
                 f'{VENDOR_RAMDISK_TABLE_ENTRY_BOARD_ID_SIZE}I',
-                args.boot_img.read(
+                boot_img.read(
                     4 * VENDOR_RAMDISK_TABLE_ENTRY_BOARD_ID_SIZE))
             output_ramdisk_name = f'vendor_ramdisk{idx:02}'
 
@@ -451,16 +452,17 @@
 
     dtb_offset = page_size * (num_boot_header_pages + num_boot_ramdisk_pages
                              ) # header + vendor_ramdisk
-    image_info_list.append((dtb_offset, info.dtb_size, 'dtb'))
+    if info.dtb_size > 0:
+        image_info_list.append((dtb_offset, info.dtb_size, 'dtb'))
 
-    create_out_dir(args.out)
+    create_out_dir(output_dir)
     for offset, size, name in image_info_list:
-        extract_image(offset, size, args.boot_img, os.path.join(args.out, name))
-    info.image_dir = args.out
+        extract_image(offset, size, boot_img, os.path.join(output_dir, name))
+    info.image_dir = output_dir
 
     if info.header_version > 3:
         vendor_ramdisk_by_name_dir = os.path.join(
-            args.out, 'vendor-ramdisk-by-name')
+            output_dir, 'vendor-ramdisk-by-name')
         create_out_dir(vendor_ramdisk_by_name_dir)
         for src, dst in vendor_ramdisk_symlinks:
             src_pathname = os.path.join('..', src)
@@ -473,19 +475,26 @@
     return info
 
 
-def unpack_image(args):
-    boot_magic = unpack('8s', args.boot_img.read(8))[0].decode()
-    args.boot_img.seek(0)
-    if boot_magic == 'ANDROID!':
-        info = unpack_boot_image(args)
-    elif boot_magic == 'VNDRBOOT':
-        info = unpack_vendor_boot_image(args)
-    else:
-        raise ValueError(f'Not an Android boot image, magic: {boot_magic}')
+def unpack_bootimg(boot_img, output_dir):
+    """Unpacks the |boot_img| to |output_dir|, and returns the 'info' object."""
+    with open(boot_img, 'rb') as image_file:
+        boot_magic = unpack('8s', image_file.read(8))[0].decode()
+        image_file.seek(0)
+        if boot_magic == 'ANDROID!':
+            info = unpack_boot_image(image_file, output_dir)
+        elif boot_magic == 'VNDRBOOT':
+            info = unpack_vendor_boot_image(image_file, output_dir)
+        else:
+            raise ValueError(f'Not an Android boot image, magic: {boot_magic}')
 
-    if args.format == 'mkbootimg':
+    return info
+
+
+def print_bootimg_info(info, output_format, null_separator):
+    """Format and print boot image info."""
+    if output_format == 'mkbootimg':
         mkbootimg_args = info.format_mkbootimg_argument()
-        if args.null:
+        if null_separator:
             print('\0'.join(mkbootimg_args) + '\0', end='')
         else:
             print(shlex.join(mkbootimg_args))
@@ -531,7 +540,7 @@
         description='Unpacks boot, recovery or vendor_boot image.',
         epilog=get_unpack_usage(),
     )
-    parser.add_argument('--boot_img', type=FileType('rb'), required=True,
+    parser.add_argument('--boot_img', required=True,
                         help='path to the boot, recovery or vendor_boot image')
     parser.add_argument('--out', default='out',
                         help='output directory of the unpacked images')
@@ -546,7 +555,8 @@
 def main():
     """parse arguments and unpack boot image"""
     args = parse_cmdline()
-    unpack_image(args)
+    info = unpack_bootimg(args.boot_img, args.out)
+    print_bootimg_info(info, args.format, args.null)
 
 
 if __name__ == '__main__':