diff --git a/src/boot_animation.py b/src/boot_animation.py index c41fed36..7efd9f78 100644 --- a/src/boot_animation.py +++ b/src/boot_animation.py @@ -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"), ) @@ -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. @@ -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 ", 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 | " + "verify-runtime ", + file=sys.stderr, + ) + return 2 if __name__ == "__main__": diff --git a/src/ci/selection_variant.sh b/src/ci/selection_variant.sh index 5debe9f2..c53b0390 100755 --- a/src/ci/selection_variant.sh +++ b/src/ci/selection_variant.sh @@ -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=( @@ -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 } diff --git a/src/util_functions.sh b/src/util_functions.sh index 830a455d..646e3d40 100755 --- a/src/util_functions.sh +++ b/src/util_functions.sh @@ -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 @@ -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}" diff --git a/tests/boot_animation_test.py b/tests/boot_animation_test.py index b09c7c5e..c8bfbad7 100644 --- a/tests/boot_animation_test.py +++ b/tests/boot_animation_test.py @@ -19,6 +19,7 @@ build_runtime_payload, install_runtime_payload, validate_payload, + verify_runtime_installation, ) @@ -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: diff --git a/tests/module_selection_test.sh b/tests/module_selection_test.sh index 156fd80e..4e398c42 100644 --- a/tests/module_selection_test.sh +++ b/tests/module_selection_test.sh @@ -56,6 +56,10 @@ verify_requested_root_outputs() { : } +verify_requested_boot_animation_outputs() { + : +} + fail() { echo "$*" >&2 exit 1