Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 33 additions & 8 deletions src/boot_animation.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@
MAX_DESCRIPTION_BYTES = 4096
PAYLOAD_ENVIRONMENT = "PIXENEOS_BOOT_ANIMATION_PATH"
BOOT_ANIMATION_TARGETS = (
("product", "/product/media/bootanimation.zip"),
("product", "/product/media/bootanimation-dark.zip"),
("product", "/media/bootanimation.zip"),
("product", "/media/bootanimation-dark.zip"),
)


Expand Down Expand Up @@ -247,6 +247,23 @@ def install_runtime_payload(ext_fs: dict[str, Any], payload: bytes) -> None:
stream.write(payload)




def verify_runtime_installation(
source_path: str | os.PathLike[str],
light_path: str | os.PathLike[str],
dark_path: str | os.PathLike[str],
) -> None:
"""Verify the custom animation extracted from a finished product image."""

expected = build_runtime_payload(source_path)
for runtime_path in (Path(light_path), Path(dark_path)):
if runtime_path.read_bytes() != expected:
raise RuntimeError(
f"finished OTA boot animation does not match payload: {runtime_path}"
)


def _module_class() -> type[Any]:
# Import only when the pinned helper imports this file. This keeps the
# standalone validator usable before the helper is fetched.
Expand Down Expand Up @@ -319,15 +336,23 @@ def inject(


def main(argv: list[str]) -> int:
if len(argv) != 3 or argv[1] not in {"validate", "digest"}:
print(f"usage: {argv[0]} validate <bootanimation.zip>", file=sys.stderr)
return 2
try:
print(validate_payload(argv[2]))
except BootAnimationError as exc:
if len(argv) == 3 and argv[1] in {"validate", "digest"}:
print(validate_payload(argv[2]))
return 0
if len(argv) == 5 and argv[1] == "verify-runtime":
verify_runtime_installation(argv[2], argv[3], argv[4])
return 0
except (BootAnimationError, OSError, RuntimeError) as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
return 0

print(
f"usage: {argv[0]} validate <bootanimation.zip> | "
"verify-runtime <source.zip> <light.zip> <dark.zip>",
file=sys.stderr,
)
return 2


if __name__ == "__main__":
Expand Down
3 changes: 3 additions & 0 deletions src/ci/selection_variant.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
# this helper owns the byte-for-byte representation that is hashed by both the
# build and release preflight paths.

BOOT_ANIMATION_SELECTION_CONTRACT="product-image-root-stored-v4"

function selection_variant_manifest() {
local field value
local -a required_fields=(
Expand Down Expand Up @@ -116,6 +118,7 @@ function selection_variant_manifest() {
if [[ "${SELECTION_BOOT_ANIMATION}" == 'true' ]]; then
printf '%s\n' \
'boot_animation=true' \
"boot_animation_contract=${BOOT_ANIMATION_SELECTION_CONTRACT}" \
"boot_animation_sha256=${SELECTION_BOOT_ANIMATION_SHA256}"
fi
}
Expand Down
109 changes: 109 additions & 0 deletions src/util_functions.sh
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,7 @@ function patch_ota() {
# output separation, but /data/adb/magisk is provisioned on-device by
# Magisk's additional-setup/environment-fix flow.
verify_requested_root_outputs || return 1
verify_requested_boot_animation_outputs || return 1

if [[ "${RESOLVED_ROOT_MODE}" == 'both' ]]; then
generate_custota_variant_sidecars "${OUTPUTS[PATCHED_OTA_ROOTLESS]}" "${OUTPUTS[OTA_METADATA_ROOTLESS]}" || return 1
Expand Down Expand Up @@ -662,6 +663,114 @@ function extract_ota_boot_target() {
printf '%s\n' "${target}"
}

function verify_boot_animation_ota() {
local ota_path="${1}"
local temp_dir payload_path avbroot_bin afsr_bin ota_abs
local extract_dir unpack_dir image_path raw_image

[[ -f "${ota_path}" ]] || {
echo "Error: missing OTA for boot-animation inspection: ${ota_path}" >&2
return 1
}

payload_path="$(_boot_animation_payload_path)" || return 1
temp_dir="$(mktemp -d "${WORKDIR}/boot-animation-verify.XXXXXX")" || return 1
temp_dir="$(realpath -- "${temp_dir}")" || return 1
ota_abs="$(realpath -- "${ota_path}")" || {
rm -rf -- "${temp_dir}"
return 1
}
avbroot_bin="$(resolve_executable_tool avbroot)" || {
rm -rf -- "${temp_dir}"
return 1
}
afsr_bin="$(resolve_executable_tool afsr)" || {
rm -rf -- "${temp_dir}"
return 1
}
avbroot_bin="$(realpath -- "${avbroot_bin}")" || {
rm -rf -- "${temp_dir}"
return 1
}
afsr_bin="$(realpath -- "${afsr_bin}")" || {
rm -rf -- "${temp_dir}"
return 1
}

extract_dir="${temp_dir}/extract-product"
unpack_dir="${temp_dir}/unpack-product"
mkdir -p -- "${extract_dir}" "${unpack_dir}" || {
rm -rf -- "${temp_dir}"
return 1
}

if ! "${avbroot_bin}" ota extract --input "${ota_abs}" --directory "${extract_dir}" --partition product >/dev/null; then
rm -rf -- "${temp_dir}"
return 1
fi

image_path="${extract_dir}/product.img"
[[ -s "${image_path}" ]] || {
echo "Error: boot-animation verification did not extract product.img." >&2
rm -rf -- "${temp_dir}"
return 1
}
image_path="$(realpath -- "${image_path}")" || {
rm -rf -- "${temp_dir}"
return 1
}

if ! (
cd -- "${unpack_dir}" &&
"${avbroot_bin}" avb unpack --quiet --input "${image_path}" &&
raw_image="$(realpath -- raw.img)" &&
"${afsr_bin}" unpack --input "${raw_image}"
); then
rm -rf -- "${temp_dir}"
return 1
fi

if ! python3 src/boot_animation.py verify-runtime "${payload_path}" "${unpack_dir}/fs_tree/media/bootanimation.zip" "${unpack_dir}/fs_tree/media/bootanimation-dark.zip"; then
rm -rf -- "${temp_dir}"
return 1
fi

rm -rf -- "${temp_dir}"
echo "Verified custom boot animation in finished OTA product image: ${ota_path}"
}

function verify_requested_boot_animation_outputs() {
local -a ota_paths=()
local ota_path

[[ "${ADDITIONALS[BOOT_ANIMATION]}" == 'true' ]] || return 0

case "${RESOLVED_ROOT_MODE}" in
rootless|magisk)
ota_paths=("${OUTPUTS[PATCHED_OTA]}")
;;
both)
ota_paths=(
"${OUTPUTS[PATCHED_OTA_ROOTLESS]}"
"${OUTPUTS[PATCHED_OTA_MAGISK]}"
)
;;
*)
echo "Error: cannot verify boot animation for root mode: ${RESOLVED_ROOT_MODE}" >&2
return 1
;;
esac

for ota_path in "${ota_paths[@]}"; do
if ! verify_boot_animation_ota "${ota_path}"; then
rm -f -- "${ota_path}" "${ota_path}.csig"
echo "Error: refusing to keep or publish an OTA without verified custom boot animation." >&2
return 1
fi
done
}


function verify_magisk_ota() {
local ota_path="${1}"
local expected_preinit="${2}"
Expand Down
17 changes: 14 additions & 3 deletions tests/boot_animation_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
build_runtime_payload,
install_runtime_payload,
validate_payload,
verify_runtime_installation,
)


Expand Down Expand Up @@ -83,15 +84,25 @@ def test_runtime_payload_installation() -> None:
install_runtime_payload({"product": product}, runtime)

expected_targets = [
"/product/media/bootanimation.zip",
"/product/media/bootanimation-dark.zip",
"/media/bootanimation.zip",
"/media/bootanimation-dark.zip",
]
assert [call[0] for call in product.open_calls] == expected_targets
assert all(call[1:] == ("wb", 0o644) for call in product.open_calls)
assert all(call[0] == "/product/media" for call in product.mkdir_calls)
assert all(call[0] == "/media" for call in product.mkdir_calls)
for target in expected_targets:
assert (product.root / target.lstrip("/")).read_bytes() == runtime

# A standalone product.img is mounted at /product. Its filesystem root
# therefore contains /media, not another nested /product directory.
assert not (product.root / "product").exists()

verify_runtime_installation(
source,
product.root / "media/bootanimation.zip",
product.root / "media/bootanimation-dark.zip",
)

try:
install_runtime_payload({}, runtime)
except RuntimeError:
Expand Down
4 changes: 4 additions & 0 deletions tests/module_selection_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ verify_requested_root_outputs() {
:
}

verify_requested_boot_animation_outputs() {
:
}

fail() {
echo "$*" >&2
exit 1
Expand Down
Loading