Skip to content

Commit

Permalink
avbroot 2.0: Rewrite in Rust
Browse files Browse the repository at this point in the history
Why?
----

It was always my intention to write avbroot in a compiled language.
Python was a stop-gap solution since it was possible to use the various
tools and parsers from AOSP to make the initial prototyping and
implementation easier. However, doing so required a whole lot of hacks
since nearly all of the Python modules we use were intended to be used
as executables, not libraries, and they were definitely not meant to be
used outside of AOSP's code base.

Although the dependencies on AOSP code have been reduced over time,
working on the Python code is still frustrating. The majority of the
modules we use from both the standard library and external dependencies
are lacking type annotations. All of the Python language servers and
type checker tools I've used choked on them. There have been serveral
avbroot bugs in the past that wouldn't have happened with any
statically typed language.

The catalyst for me working on this recently was dealing with some
python-protobuf versions that wouldn't work with AOSP's pregenerated
protobuf bindings. When parsing protobuf messages, it would fail
with obscure runtime type errors. I need my projects to not feel
frustrating or else I'll just get burnt out.

Hence, the Rust rewrite. With fewer hacks this time! avbroot no longer
has any dependencies on external tools like openssl. I'll be providing
precompiled binaries for the three major desktop OS's, built by GitHub
Actions. avbroot will also be versioned now, starting at 2.0.0.

Whats new?
----------

* A new `avbroot ota verify` subcommand has been added to check that all
  OTA and AVB related components have been properly hashed and signed.
  This works for all OTA images, including stock ones.
* A couple new `avbroot avb` subcommands have been added for dumping
  vbmeta header/footer information and verifying AVB signatures. These
  are roughly equivalent to avbtool's `info_image` and `verify_image`
  subcommands, though avbroot is about an order of magnitude faster than
  the latter.
* A new set of `avbroot boot` subcommands have been added for packing
  and unpacking boot images. It supports Android v0-v4 images and vendor
  v3-v4 images. Repacking is lossless even when using deprecated fields,
  like the boot image v4 VTS signature.
* A new `avbroot ramdisk` subcommand has been added for inspecting
  the CPIO structure of ramdisks.
* A new set of `avbroot key` subcommands have been added for generating
  signing keys so that it's no longer necessary to install openssl and
  avbtool (though of course, keys generated by other tools remain fully
  compatible).
* Since avbroot has a ton of CLI options, a new `avbroot completion`
  subcommand has been added for generating tab-completion configs for
  various shells (eg. bash, zsh, fish, powershell).

What was removed?
-----------------

Nothing :) The `patch` and `extract` subcommands have been moved under
`avbroot ota` and the `magisk-info` subcommand has been moved under
`avbroot boot`, but there are compatibility shims in place to keep all
the old commands working.

The command-line interface will remain backwards compatible for as long
as possible, even with new major releases. The Rust API, however, has no
backwards compatibility guarantees. I currently don't intend for
avbroot's "library" components to be used anywhere outside of Custota
and avbroot itself.

Performance
-----------

Due to having better access to low-level APIs (especially `pread` and
`pwrite`), nearly everything that can be multithreaded in avbroot is now
multithreaded. In addition, during the patching operation, everything
is done entirely in memory without temp files and the maximum memory
usage is still about 100MB lower than with the Python implementation.

The new implementation is bottlenecked by how fast a single CPU core can
calculate 3 SHA256 hashes of overlapping regions spanning the majority
of the OTA file. About 90% of the CPU time is spent calculating SHA256
hashes and another 5% or so performing XZ-compression.

Some numbers:

* Patching should take roughly 40%-70% of the time it took before.
* Extracting with `--all` should take roughly 10%-30% of the time it
  took before.

Folks with x86_64 CPUs supporting SHA-NI extensions (eg. Intel 11th gen
and newer) should see even bigger improvements.

Reproducibility
---------------

The new implementation's output files are bit-for-bit identical when the
inputs are the same. However, they do not exactly match what the Python
implementation produced.

* The zip entries, aside from `metadata` and `metadata.pb`, are written
  in sorted order.
* All zip entries are stored without compression.
* All zip entries are stored without additional metadata (eg.
  modification timestamp).
* The OTA certificate, both in the OTA zip and in the recovery ramdisk's
  `otacerts.zip`, goes through deserialization + serialization before
  being written. Text in the certificate file before the header and
  after the footer will be stripped out.
* The protobuf structures (payload header and OTA metadata) are
  serialized differently. Protobuf has more than one way to encode the
  same messages "on the wire". The Rust quick_protobuf library
  serializes messages a bit differently than python-protobuf, but the
  outputs are mutually compatible.
* XZ compression of modified partition images in the payload is now done
  at compression level 0 instead of 6. This reduces the patching time by
  several seconds at the cost of a couple MiB increase in file size.
* Ramdisks are now compressed with standard LZ4 instead of LZ4HC (high
  compression mode). For our use case, the difference is <100 KiB, but
  using standard LZ4 allows us to use a pure-Rust LZ4 library and makes
  the compression step much faster.
* Older ramdisks compressed with gzip are slightly different due to a
  different gzip implementation being used (flate2 vs. zlib). The two
  implementations structure the gzip frames slightly differently, but
  the output is identical when decompressed.
* Magisk's config file in the ramdisk (`.backup/.magisk`) will have the
  `SHA1` field set to all zeros. This allows avbroot to keep track of
  less information during patching for better performance. The field is
  only used for Magisk's uninstall feature, which can't ever be used in
  a locked bootloader setup anyway.

Misc
----

While working on the new `avbroot ota verify` subcommand, I found that
the `ossi` stock image (OnePlus 10 Pro) used in avbroot's tests has an
invalid vbmeta hash for the `odm` partition. I thought it was an avbroot
bug, but AOSP's avbtool reports the same invalid hash too. If that image
actually boots, then I'm not sure AVB can be trusted on those devices...

Signed-off-by: Andrew Gunnerson <[email protected]>
  • Loading branch information
chenxiaolong committed Aug 28, 2023
1 parent ac80abe commit 9095616
Show file tree
Hide file tree
Showing 102 changed files with 15,257 additions and 6,245 deletions.
33 changes: 14 additions & 19 deletions .github/actions/preload-img-cache/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,39 +15,34 @@ runs:
with:
key: ${{ inputs.cache-key-prefix }}${{ inputs.device }}
# Make sure any changes to path are also reflected in ci.yml setup
path: tests/files/${{ inputs.device }}-sparse.tar
path: e2e/files/${{ inputs.device }}-sparse.tar

- if: ${{ steps.cache-img.outputs.cache-hit }}
name: Extracting image from sparse archive
shell: sh
run: |
tar -C tests/files -xf tests/files/${{ inputs.device }}-sparse.tar
- if: ${{ ! steps.cache-img.outputs.cache-hit }}
uses: awalsh128/cache-apt-pkgs-action@v1
with:
packages: python3-lz4 python3-protobuf
working-directory: e2e/files
run: tar -xf ${{ inputs.device }}-sparse.tar

- if: ${{ ! steps.cache-img.outputs.cache-hit }}
uses: awalsh128/cache-apt-pkgs-action@v1
- name: Restore e2e executable
if: ${{ ! steps.cache-img.outputs.cache-hit }}
uses: actions/cache/restore@v3
with:
packages: python3-strictyaml
key: e2e-${{ github.sha }}-${{ runner.os }}
fail-on-cache-miss: true
path: |
target/release/e2e
target/release/e2e.exe
- name: Downloading device image for ${{ inputs.device }}
if: ${{ ! steps.cache-img.outputs.cache-hit }}
shell: sh
run: |
./tests/tests.py \
download \
--stripped \
--no-magisk \
--device \
${{ inputs.device }}
working-directory: e2e
run: ../target/release/e2e download --stripped -d ${{ inputs.device }}

- if: ${{ ! steps.cache-img.outputs.cache-hit }}
name: Creating sparse archive from image
shell: sh
working-directory: e2e/files
run: |
cd tests/files
tar --sparse -cf ${{ inputs.device }}-sparse.tar \
${{ inputs.device }}/*.stripped
25 changes: 11 additions & 14 deletions .github/actions/preload-magisk-cache/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,23 +12,20 @@ runs:
with:
key: ${{ inputs.cache-key }}
# Make sure any changes to path are also reflected in ci.yml setup
path: tests/files/magisk
path: e2e/files/magisk

- if: ${{ ! steps.cache-magisk.outputs.cache-hit }}
uses: awalsh128/cache-apt-pkgs-action@v1
with:
packages: python3-lz4 python3-protobuf

- if: ${{ ! steps.cache-magisk.outputs.cache-hit }}
uses: awalsh128/cache-apt-pkgs-action@v1
- name: Restore e2e executable
if: ${{ ! steps.cache-magisk.outputs.cache-hit }}
uses: actions/cache/restore@v3
with:
packages: python3-strictyaml
key: e2e-${{ github.sha }}-${{ runner.os }}
fail-on-cache-miss: true
path: |
target/release/e2e
target/release/e2e.exe
- name: Downloading Magisk
if: ${{ ! steps.cache-magisk.outputs.cache-hit }}
shell: sh
run: |
./tests/tests.py \
download \
--magisk \
--no-devices
working-directory: e2e
run: ../target/release/e2e download --magisk
30 changes: 0 additions & 30 deletions .github/actions/preload-tox-cache/action.yml

This file was deleted.

180 changes: 110 additions & 70 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,80 +11,139 @@ concurrency:
cancel-in-progress: true

jobs:
build:
runs-on: ${{ matrix.os }}
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: -C strip=symbols
strategy:
fail-fast: false
matrix:
os:
- ubuntu-latest
- windows-latest
- macos-latest
steps:
- name: Check out repository
uses: actions/checkout@v3
with:
# For git describe
fetch-depth: 0

- name: Get version
id: get_version
shell: bash
run: |
echo -n 'version=' >> "${GITHUB_OUTPUT}"
git describe --always \
| sed -E "s/^v//g;s/([^-]*-g)/r\1/;s/-/./g" \
>> "${GITHUB_OUTPUT}"
- name: Get Rust LLVM target triple
id: get_target
shell: bash
env:
RUSTC_BOOTSTRAP: '1'
run: |
echo -n 'name=' >> "${GITHUB_OUTPUT}"
rustc -Z unstable-options --print target-spec-json \
| jq -r '."llvm-target"' \
>> "${GITHUB_OUTPUT}"
- name: Cache Rust dependencies
uses: Swatinem/rust-cache@v2

- name: Clippy
run: cargo clippy --release --workspace --features static

- name: Build
run: cargo build --release --workspace --features static

- name: Tests
run: cargo test --release --workspace --features static

- name: Archive documentation
uses: actions/upload-artifact@v3
with:
name: avbroot-${{ steps.get_version.outputs.version }}-${{ steps.get_target.outputs.name }}
path: |
LICENSE
README.md
# This is separate so we can have a flat directory structure.
- name: Archive executable
uses: actions/upload-artifact@v3
with:
name: avbroot-${{ steps.get_version.outputs.version }}-${{ steps.get_target.outputs.name }}
path: |
target/release/avbroot
target/release/avbroot.exe
- name: Cache e2e executable
uses: actions/cache@v3
with:
key: e2e-${{ github.sha }}-${{ runner.os }}
path: |
target/release/e2e
target/release/e2e.exe
setup:
name: Prepare workflow data
runs-on: ubuntu-latest
needs: build
timeout-minutes: 2
outputs:
config-path: ${{ steps.load-config.outputs.config-path }}
device-list: ${{ steps.load-config.outputs.device-list }}
magisk-key: ${{ steps.cache-keys.outputs.magisk-key }}
img-key-prefix: ${{ steps.cache-keys.outputs.img-key-prefix }}
img-hit: ${{ steps.get-img-cache.outputs.cache-matched-key }}
tox-key-prefix: ${{ steps.cache-keys.outputs.tox-key-prefix }}
tox-hit: ${{ steps.get-tox-cache.outputs.cache-matched-key }}
steps:
- uses: actions/checkout@v3
with:
submodules: true

- uses: awalsh128/cache-apt-pkgs-action@v1
- name: Restore e2e executable
uses: actions/cache/restore@v3
with:
packages: python3-strictyaml
key: e2e-${{ github.sha }}-${{ runner.os }}
fail-on-cache-miss: true
path: |
target/release/e2e
target/release/e2e.exe
- name: Loading test config
id: load-config
shell: python
working-directory: e2e
run: |
import json
import os
import sys
sys.path.append(os.environ['GITHUB_WORKSPACE'])
import tests.config
config_data = tests.config.load_config()
devices = [d.data for d in config_data['device']]
with open(os.environ['GITHUB_OUTPUT'], 'a') as f:
f.write(f'config-path={tests.config.CONFIG_PATH}\n')
f.write(f"device-list={json.dumps(devices)}\n")
echo 'config-path=e2e/e2e.toml' >> "${GITHUB_OUTPUT}"
echo -n 'device-list=' >> "${GITHUB_OUTPUT}"
../target/release/e2e list \
| jq -cnR '[inputs | select(length > 0)]' \
>> "${GITHUB_OUTPUT}"
- name: Generating cache keys
id: cache-keys
run: |
{
echo "tox-key-prefix=tox-${{ hashFiles('tox.ini') }}-"; \
echo "img-key-prefix=img-${{ hashFiles(steps.load-config.outputs.config-path) }}-"; \
echo "magisk-key=magisk-${{ hashFiles(steps.load-config.outputs.config-path) }}";
} >> $GITHUB_OUTPUT
- name: Checking for cached tox environments
id: get-tox-cache
uses: actions/cache/restore@v3
with:
key: ${{ steps.cache-keys.outputs.tox-key-prefix }}
lookup-only: true
path: |
.tox/
~/.cache/pip
- name: Checking for cached device images
id: get-img-cache
uses: actions/cache/restore@v3
with:
key: ${{ steps.cache-keys.outputs.img-key-prefix }}
lookup-only: true
path: |
tests/files/${{ fromJSON(steps.load-config.outputs.device-list)[0] }}-sparse.tar
e2e/files/${{ fromJSON(steps.load-config.outputs.device-list)[0] }}-sparse.tar
- name: Checking for cached magisk apk
id: get-magisk-cache
uses: actions/cache/restore@v3
with:
key: ${{ steps.cache-keys.outputs.magisk-key }}
lookup-only: true
path: tests/files/magisk
path: e2e/files/magisk

- name: Preloading Magisk cache
if: ${{ ! steps.get-magisk-cache.outputs.cache-hit }}
Expand All @@ -106,54 +165,31 @@ jobs:
device: ${{ fromJSON(needs.setup.outputs.device-list) }}
steps:
- uses: actions/checkout@v3
with:
submodules: true

- name: Preloading image cache
uses: ./.github/actions/preload-img-cache
with:
cache-key-prefix: ${{ needs.setup.outputs.img-key-prefix }}
device: ${{ matrix.device }}

preload-tox:
name: Preload tox environments
runs-on: ubuntu-latest
needs: setup
timeout-minutes: 5
# Assume that preloading always succesfully cached all tox environments before.
# If for some reason only some got cached, on the first run, the cache will not be preloaded
# which will result in some being downloaded multiple times when running the tests.
if: ${{ ! needs.setup.outputs.tox-hit }}
strategy:
matrix:
python: [py39, py310, py311]
steps:
- uses: actions/checkout@v3

- name: Preloading tox cache
uses: ./.github/actions/preload-tox-cache
with:
cache-key-prefix: ${{ needs.setup.outputs.tox-key-prefix }}
python-version: ${{ matrix.python }}

- name: Generating tox environment
run: tox -e ${{ matrix.python }} --notest

tests:
name: Run test for ${{ matrix.device }} with ${{ matrix.python }}
name: Run test for ${{ matrix.device }} on ${{ matrix.os }}
runs-on: ubuntu-latest
needs: [setup, preload-img, preload-tox]
needs:
- setup
- preload-img
timeout-minutes: 10
# Continue on skipped but not on failures or cancels
if: ${{ always() && ! failure() && ! cancelled() }}
strategy:
matrix:
device: ${{ fromJSON(needs.setup.outputs.device-list) }}
python: [py39, py310, py311]
os:
- ubuntu-latest
- windows-latest
- macos-latest
steps:
- uses: actions/checkout@v3
with:
submodules: true

- name: Restoring Magisk cache
uses: ./.github/actions/preload-magisk-cache
Expand All @@ -166,12 +202,16 @@ jobs:
cache-key-prefix: ${{ needs.setup.outputs.img-key-prefix }}
device: ${{ matrix.device }}

- name: Restoring tox cache
uses: ./.github/actions/preload-tox-cache
- name: Restore e2e executable
uses: actions/cache/restore@v3
with:
cache-key-prefix: ${{ needs.setup.outputs.tox-key-prefix }}
python-version: ${{ matrix.python }}
key: e2e-${{ github.sha }}-${{ runner.os }}
fail-on-cache-miss: true
path: |
target/release/e2e
target/release/e2e.exe
# Finally run tests
- name: Run test for ${{ matrix.device }} with ${{ matrix.python }}
run: tox -e ${{ matrix.python }} -- --stripped -d ${{ matrix.device }}
- name: Run test for ${{ matrix.device }}
working-directory: e2e
run: ../target/release/e2e test --stripped -d ${{ matrix.device }}
16 changes: 16 additions & 0 deletions .github/workflows/deny.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
on:
push:
branches:
- master
pull_request:
jobs:
check:
name: cargo-deny
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v3

- name: Run cargo-deny
uses: EmbarkStudios/cargo-deny-action@v1
Loading

0 comments on commit 9095616

Please sign in to comment.