blob: 77c9ccadab30f322392d9040c20609a591bc8d83 [file] [log] [blame]
#!/usr/bin/env fuchsia-vendored-python
# Copyright 2025 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Android boot image utility.
This script contains some Fuchsia-specific logic, e.g. checking for concatenated boot
images in a single file.
Upstream tooling with more standard functionality can be found at
https://android.googlesource.com/platform/system/tools/mkbootimg/+/refs/heads/main.
Example usage:
1. Dump the ramdisk:
$ android_boot_image.py boot.img --dump_ramdisk ramdisk.img
2. Replace the ramdisk:
$ android_boot_image.py boot.img --replace_ramdisk new_ramdisk.img
"""
import argparse
import dataclasses
import enum
import pathlib
import struct
class ChunkType(enum.Enum):
"""The Android boot image chunk types."""
KERNEL = "kernel"
RAMDISK = "ramdisk"
SECOND = "second"
RECOVERY_DTBO = "recovery DTBO"
DTB = "DTB"
@dataclasses.dataclass
class ImageChunk:
"""A single data chunk in an Android boot image."""
# Chunk type.
chunk_type: ChunkType
# Chunk size (without padding).
size: int
# Chunk offset in the image.
offset: int
# Offset to the chunk size U32 value in the header (without padding).
size_offset: int
class AndroidBootImage:
"""An Android boot image."""
# First 8 bytes for all versions.
MAGIC = b"ANDROID!"
# The header version is a little-endian U32 at this offset.
VERSION_OFFSET = 40
def __init__(self, image: bytes):
"""Creates an AndroidBootImage from the raw bytes"""
self._load(image)
def _load(self, image: bytes) -> None:
"""Loads the given image.
Raises an exception if the image looks wrong or isn't a supported version.
"""
if not image.startswith(self.MAGIC):
raise ValueError("Not an Android boot image")
# Currently we only ever produce v2 boot images, so for simplicity that's all
# we support here, but if we want other versions it should just be a matter
# of specifying the correct chunks below.
self.version = struct.unpack_from("<I", image, self.VERSION_OFFSET)[0]
if self.version != 2:
raise NotImplementedError(
f"Only v2 is supported (v={self.version})"
)
self.page_size = struct.unpack_from("<I", image, 36)[0]
# v2 image chunks appear in this order and are all padded to page alignment.
chunk_type_and_offset = (
(ChunkType.KERNEL, 8),
(ChunkType.RAMDISK, 16),
(ChunkType.SECOND, 24),
(ChunkType.RECOVERY_DTBO, 1632),
(ChunkType.DTB, 1648),
)
# Unpack the chunks, starting at page 1 (after the header).
self.chunks = []
self.total_size = self.page_size
for chunk_type, size_offset in chunk_type_and_offset:
size = struct.unpack_from("<I", image, size_offset)[0]
self.chunks.append(
ImageChunk(
chunk_type=chunk_type,
size=size,
offset=self.total_size,
size_offset=size_offset,
)
)
self.total_size += self._align(size)
self.image = image[: self.total_size]
def _align(self, offset: int) -> int:
"""Rounds the given offset up to the page alignment."""
return (
(offset + (self.page_size - 1)) // self.page_size * self.page_size
)
def get_chunk(self, chunk_type: ChunkType) -> ImageChunk:
"""Returns the requested chunk, or raises an exception."""
return [c for c in self.chunks if c.chunk_type == chunk_type][0]
def get_chunk_data(self, chunk_type: ChunkType) -> bytes:
"""Returns a copy of the given chunk's data without padding."""
chunk = self.get_chunk(chunk_type)
return self.image[chunk.offset : chunk.offset + chunk.size]
def replace_chunk(self, chunk_type: ChunkType, new_contents: bytes) -> None:
"""Replaces the given chunk with the new contents."""
padding_size = self._align(len(new_contents)) - len(new_contents)
chunk = self.get_chunk(chunk_type)
# The recovery DTBO is unique in that the header also tracks its data offset.
# We currently don't use a recovery DTBO and it adds a bit more complexity,
# so for now just double-check that it doesn't exist so we can ignore it.
if self.get_chunk(ChunkType.RECOVERY_DTBO).size != 0:
raise NotImplementedError(
"Replacing chunks not supported when recovery DTBO exists"
)
# Create the new image, replacing the chunk and its size in the header.
new_image = (
# Header up until the size field.
self.image[: chunk.size_offset]
# New chunk size.
+ struct.pack("<I", len(new_contents))
# Rest of the header and data until the chunk start.
+ self.image[chunk.size_offset + 4 : chunk.offset]
# New chunk data.
+ new_contents
# New chunk padding.
+ b"\x00" * padding_size
# Rest of the chunks.
+ self.image[chunk.offset + self._align(chunk.size) :]
)
# Re-load to update our internal data for the new contents.
self._load(new_image)
def load_images(contents: bytes) -> list[AndroidBootImage]:
"""Loads any number of consecutive boot images."""
images = []
while contents:
# Extract the current image.
image = AndroidBootImage(contents)
images.append(image)
# Advance contents to look for the next one.
contents = contents[image.total_size :]
return images
def _print_images_info(images: list[AndroidBootImage]) -> None:
"""Prints images summary to stdout."""
single_image = len(images) == 1
if not single_image:
print(f"Found {len(images)} images")
for i, image in enumerate(images):
if not single_image:
print(f"== Image {i} ==")
print(f"Version: {image.version}")
for chunk in image.chunks:
print(f"{chunk.chunk_type.value} size: {chunk.size}")
print(f"Total size including padding: {image.total_size}")
def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
"""Parses script arguments.
argv can be specified for tests, or None to use the actual commandline.
"""
parser = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"file", type=pathlib.Path, help="Path to Android boot image"
)
# Currently this expect an int index, but we could add more power here e.g.
# --select-image=kernel to auto-detect which one is the kernel boot image.
parser.add_argument(
"--select_image",
type=int,
help="If the file contains multiple boot images, which one to use (0-based)",
)
parser.add_argument(
"--split",
action="store_true",
help="Split the image into separate boot images",
)
parser.add_argument(
"--dump_ramdisk",
type=pathlib.Path,
help="Dump the ramdisk to this path",
)
parser.add_argument(
"--replace_ramdisk",
type=pathlib.Path,
help="Replace the ramdisk with the image at this path",
)
return parser.parse_args(argv)
def _select_image(
index: int | None, images: list[AndroidBootImage]
) -> tuple[int, AndroidBootImage]:
"""Returns the selected index and boot image.
If there is only one boot image, index is allowed to be None. Otherwise index must
select a valid boot image from the list.
Raises an exception if the requested image is not available.
"""
if not images:
raise ValueError("No boot images were found")
if index is None:
if len(images) == 1:
return 0, images[0]
raise ValueError(
f"{len(images)} images were found, you must select one"
)
return index, images[index]
def main(argv: list[str] | None = None) -> None:
"""Main entry point.
argv can be specified for tests, or None to use the actual commandline.
"""
args = _parse_args(argv)
contents = args.file.read_bytes()
images = load_images(contents)
_print_images_info(images)
if args.split:
# Output name is the same as input name with an additional index suffix.
print(f"Splitting {len(images)} images to {args.file}.*")
offset = 0
for i, image in enumerate(images):
out_path = args.file.with_suffix(args.file.suffix + f".{i}")
out_path.write_bytes(contents[offset : offset + image.total_size])
offset += image.total_size
# Dump first so that if we also replace the ramdisk, we dump the old one.
if args.dump_ramdisk:
_, image = _select_image(args.select_image, images)
args.dump_ramdisk.write_bytes(image.get_chunk_data(ChunkType.RAMDISK))
print(f"Wrote ramdisk to {args.dump_ramdisk}")
if args.replace_ramdisk:
index, image = _select_image(args.select_image, images)
image.replace_chunk(
ChunkType.RAMDISK, args.replace_ramdisk.read_bytes()
)
images[index] = image
args.file.write_bytes(b"".join(i.image for i in images))
print(f"Replaced {args.file} ramdisk with {args.replace_ramdisk}")
if __name__ == "__main__":
main()