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
63 changes: 49 additions & 14 deletions src/boot_animation.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
MAX_COMPRESSION_RATIO = 200
MAX_DESCRIPTION_BYTES = 4096
PAYLOAD_ENVIRONMENT = "PIXENEOS_BOOT_ANIMATION_PATH"
DARK_PAYLOAD_ENVIRONMENT = "PIXENEOS_BOOT_ANIMATION_DARK_PATH"
BOOT_ANIMATION_TARGETS = (
("product", "/media/bootanimation.zip"),
("product", "/media/bootanimation-dark.zip"),
Expand Down Expand Up @@ -232,8 +233,19 @@ def build_runtime_payload(path: str | os.PathLike[str]) -> bytes:
return output.getvalue()


def install_runtime_payload(ext_fs: dict[str, Any], payload: bytes) -> None:
"""Install through ExtFs so AFSR metadata and SELinux labels stay in sync."""
def install_runtime_payload(
ext_fs: dict[str, Any],
light_payload: bytes,
dark_payload: bytes | None = None,
) -> None:
"""Install theme payloads through ExtFs with single-file fallback."""

if dark_payload is None:
dark_payload = light_payload
payload_by_target = {
"/media/bootanimation.zip": light_payload,
"/media/bootanimation-dark.zip": dark_payload,
}

for partition, raw_target in BOOT_ANIMATION_TARGETS:
fs = ext_fs.get(partition)
Expand All @@ -244,20 +256,41 @@ def install_runtime_payload(ext_fs: dict[str, Any], payload: bytes) -> None:
target = PurePosixPath(raw_target)
fs.mkdir(str(target.parent), mode=0o755, parents=True, exist_ok=True)
with fs.open(str(target), "wb", mode=0o644) as stream:
stream.write(payload)
stream.write(payload_by_target[raw_target])


def resolve_runtime_payloads(
light_path: str | os.PathLike[str] | None,
dark_path: str | os.PathLike[str] | None,
) -> tuple[bytes, bytes]:
"""Resolve one or two source archives into light/dark runtime payloads."""

if not light_path and not dark_path:
raise RuntimeError("no boot animation payload is configured")
light_source = light_path or dark_path
dark_source = dark_path or light_path
assert light_source is not None
assert dark_source is not None
return build_runtime_payload(light_source), build_runtime_payload(dark_source)


def verify_runtime_installation(
source_path: str | os.PathLike[str],
light_source_path: str | os.PathLike[str],
dark_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."""
"""Verify theme payloads extracted from a finished product image."""

expected = build_runtime_payload(source_path)
for runtime_path in (Path(light_path), Path(dark_path)):
expected_light, expected_dark = resolve_runtime_payloads(
light_source_path,
dark_source_path,
)
actual = (
(Path(light_path), expected_light),
(Path(dark_path), expected_dark),
)
for runtime_path, expected in actual:
if runtime_path.read_bytes() != expected:
raise RuntimeError(
f"finished OTA boot animation does not match payload: {runtime_path}"
Expand Down Expand Up @@ -317,10 +350,12 @@ def inject(
) -> None:
del boot_fs, sepolicies, compatible_sepolicy
payload_path = os.environ.get(PAYLOAD_ENVIRONMENT)
if not payload_path:
raise RuntimeError(f"{PAYLOAD_ENVIRONMENT} is not set")
payload = build_runtime_payload(payload_path)
install_runtime_payload(ext_fs, payload)
dark_payload_path = os.environ.get(DARK_PAYLOAD_ENVIRONMENT)
light_payload, dark_payload = resolve_runtime_payloads(
payload_path,
dark_payload_path,
)
install_runtime_payload(ext_fs, light_payload, dark_payload)

return BootAnimationMod

Expand All @@ -340,16 +375,16 @@ def main(argv: list[str]) -> int:
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])
if len(argv) == 6 and argv[1] == "verify-runtime":
verify_runtime_installation(argv[2], argv[3], argv[4], argv[5])
return 0
except (BootAnimationError, OSError, RuntimeError) as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1

print(
f"usage: {argv[0]} validate <bootanimation.zip> | "
"verify-runtime <source.zip> <light.zip> <dark.zip>",
"verify-runtime <light-source.zip> <dark-source.zip> <light.zip> <dark.zip>",
file=sys.stderr,
)
return 2
Expand Down
2 changes: 1 addition & 1 deletion src/ci/selection_variant.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
# 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"
BOOT_ANIMATION_SELECTION_CONTRACT="product-image-root-theme-fallback-stored-v5"

function selection_variant_manifest() {
local field value
Expand Down
34 changes: 29 additions & 5 deletions src/rom_profiles.sh
Original file line number Diff line number Diff line change
Expand Up @@ -134,16 +134,40 @@ function _locked_input_digest() {
printf '%s\n' "${digest}"
}

function _boot_animation_payload_path() {
function _resolve_boot_animation_payloads() {
local repository_root
repository_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)" || return 1
printf '%s\n' "${repository_root}/custom/boot-animation/bootanimation.zip"

BOOT_ANIMATION_LIGHT_PAYLOAD="${repository_root}/custom/boot-animation/bootanimation.zip"
BOOT_ANIMATION_DARK_PAYLOAD="${repository_root}/custom/boot-animation/bootanimation-dark.zip"

if [[ ! -e "${BOOT_ANIMATION_LIGHT_PAYLOAD}" && ! -L "${BOOT_ANIMATION_LIGHT_PAYLOAD}" ]]; then
BOOT_ANIMATION_LIGHT_PAYLOAD=''
fi
if [[ ! -e "${BOOT_ANIMATION_DARK_PAYLOAD}" && ! -L "${BOOT_ANIMATION_DARK_PAYLOAD}" ]]; then
BOOT_ANIMATION_DARK_PAYLOAD=''
fi
if [[ -z "${BOOT_ANIMATION_LIGHT_PAYLOAD}" && -z "${BOOT_ANIMATION_DARK_PAYLOAD}" ]]; then
echo "Error: enabled boot animation requires bootanimation.zip or bootanimation-dark.zip." >&2
return 1
fi

BOOT_ANIMATION_LIGHT_PAYLOAD="${BOOT_ANIMATION_LIGHT_PAYLOAD:-${BOOT_ANIMATION_DARK_PAYLOAD}}"
BOOT_ANIMATION_DARK_PAYLOAD="${BOOT_ANIMATION_DARK_PAYLOAD:-${BOOT_ANIMATION_LIGHT_PAYLOAD}}"
}

function _boot_animation_payload_path() {
_resolve_boot_animation_payloads || return 1
printf '%s\n' "${BOOT_ANIMATION_LIGHT_PAYLOAD}"
}

function _boot_animation_payload_digest() {
local payload_path
payload_path="$(_boot_animation_payload_path)" || return 1
python3 src/boot_animation.py digest "${payload_path}"
local light_digest dark_digest
_resolve_boot_animation_payloads || return 1
light_digest="$(python3 src/boot_animation.py digest "${BOOT_ANIMATION_LIGHT_PAYLOAD}")" || return 1
dark_digest="$(python3 src/boot_animation.py digest "${BOOT_ANIMATION_DARK_PAYLOAD}")" || return 1
printf 'light=%s\ndark=%s\n' "${light_digest}" "${dark_digest}" |
sha256sum | awk '{print $1}'
}

function module_selection_fingerprint() {
Expand Down
29 changes: 21 additions & 8 deletions src/util_functions.sh
Original file line number Diff line number Diff line change
Expand Up @@ -244,15 +244,18 @@ function append_enabled_module_arguments() {
# API used by src/debugmod.py at the pinned helper revision.
function prepare_boot_animation_module() {
local helper_root="${1}"
local repository_root payload_path init_file registry_file module_source
repository_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)" || return 1
payload_path="${repository_root}/custom/boot-animation/bootanimation.zip"
local payload_path dark_payload_path init_file registry_file module_source

if [[ "${ADDITIONALS[BOOT_ANIMATION]}" != 'true' ]]; then
return 0
fi

if ! python3 src/boot_animation.py validate "${payload_path}" >/dev/null; then
_resolve_boot_animation_payloads || return 1
payload_path="${BOOT_ANIMATION_LIGHT_PAYLOAD}"
dark_payload_path="${BOOT_ANIMATION_DARK_PAYLOAD}"

if ! python3 src/boot_animation.py validate "${payload_path}" >/dev/null ||
! python3 src/boot_animation.py validate "${dark_payload_path}" >/dev/null; then
echo "Error: boot animation validation failed; refusing to patch." >&2
return 1
fi
Expand Down Expand Up @@ -310,6 +313,7 @@ function prepare_boot_animation_module() {
: >"${WORKDIR}/modules/boot-animation.zip"
: >"${WORKDIR}/signatures/boot-animation.zip.sig"
export PIXENEOS_BOOT_ANIMATION_PATH="${payload_path}"
export PIXENEOS_BOOT_ANIMATION_DARK_PATH="${dark_payload_path}"
}

# Resolve and acquire the locked F-Droid inputs before exposing them to the
Expand Down Expand Up @@ -665,15 +669,17 @@ function extract_ota_boot_target() {

function verify_boot_animation_ota() {
local ota_path="${1}"
local temp_dir payload_path avbroot_bin afsr_bin ota_abs
local temp_dir payload_path dark_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
_resolve_boot_animation_payloads || return 1
payload_path="${BOOT_ANIMATION_LIGHT_PAYLOAD}"
dark_payload_path="${BOOT_ANIMATION_DARK_PAYLOAD}"
temp_dir="$(mktemp -d "${WORKDIR}/boot-animation-verify.XXXXXX")" || return 1
temp_dir="$(realpath -- "${temp_dir}")" || return 1
ota_abs="$(realpath -- "${ota_path}")" || {
Expand Down Expand Up @@ -704,7 +710,10 @@ function verify_boot_animation_ota() {
return 1
}

if ! "${avbroot_bin}" ota extract --input "${ota_abs}" --directory "${extract_dir}" --partition product >/dev/null; then
if ! "${avbroot_bin}" ota extract \
--input "${ota_abs}" \
--directory "${extract_dir}" \
--partition product >/dev/null; then
rm -rf -- "${temp_dir}"
return 1
fi
Expand All @@ -730,7 +739,11 @@ function verify_boot_animation_ota() {
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
if ! python3 src/boot_animation.py verify-runtime \
"${payload_path}" \
"${dark_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
Expand Down
39 changes: 39 additions & 0 deletions tests/boot_animation_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
BootAnimationError,
build_runtime_payload,
install_runtime_payload,
resolve_runtime_payloads,
validate_payload,
verify_runtime_installation,
)
Expand Down Expand Up @@ -97,12 +98,50 @@ def test_runtime_payload_installation() -> None:
# therefore contains /media, not another nested /product directory.
assert not (product.root / "product").exists()

light_runtime, dark_runtime = resolve_runtime_payloads(source, None)
assert light_runtime == dark_runtime == runtime
verify_runtime_installation(
source,
source,
product.root / "media/bootanimation.zip",
product.root / "media/bootanimation-dark.zip",
)

dark_source = root / "dark-source.zip"
write_valid(dark_source, frame=b"dark-frame")
dark_runtime = build_runtime_payload(dark_source)
themed_product = FakeExtFs(root / "themed-product-fs")
resolved_light, resolved_dark = resolve_runtime_payloads(source, dark_source)
assert resolved_light == runtime
assert resolved_dark == dark_runtime
install_runtime_payload(
{"product": themed_product},
resolved_light,
resolved_dark,
)
assert (
themed_product.root / "media/bootanimation.zip"
).read_bytes() == runtime
assert (
themed_product.root / "media/bootanimation-dark.zip"
).read_bytes() == dark_runtime
verify_runtime_installation(
source,
dark_source,
themed_product.root / "media/bootanimation.zip",
themed_product.root / "media/bootanimation-dark.zip",
)

dark_only_light, dark_only_dark = resolve_runtime_payloads(None, dark_source)
assert dark_only_light == dark_only_dark == dark_runtime

try:
resolve_runtime_payloads(None, None)
except RuntimeError:
pass
else:
raise AssertionError("missing light and dark payloads were accepted")

try:
install_runtime_payload({}, runtime)
except RuntimeError:
Expand Down
22 changes: 21 additions & 1 deletion tests/rom_contract_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,10 @@ with zipfile.ZipFile(sys.argv[1], "w") as archive:
archive.writestr("desc.txt", "1 1 1\np 1 0 part0\n")
archive.writestr("part0/frame.png", b"frame")
PY
_boot_animation_payload_path() { printf '%s\n' "${TEST_ROOT}/custom/boot-animation/bootanimation.zip"; }
_resolve_boot_animation_payloads() {
BOOT_ANIMATION_LIGHT_PAYLOAD="${TEST_ROOT}/custom/boot-animation/bootanimation.zip"
BOOT_ANIMATION_DARK_PAYLOAD="${BOOT_ANIMATION_LIGHT_PAYLOAD}"
}
boot_changed="$(fingerprint)"
[[ "${boot_changed}" != "${module_changed}" ]] ||
fail "boot animation selection did not change the fingerprint"
Expand All @@ -234,6 +237,23 @@ PY
second_boot_changed="$(fingerprint)"
[[ "${second_boot_changed}" != "${boot_changed}" ]] ||
fail "enabled boot-animation payload change did not change the fingerprint"

python3 - "${TEST_ROOT}/custom/boot-animation/bootanimation-dark.zip" <<'PY'
import sys
import zipfile

with zipfile.ZipFile(sys.argv[1], "w") as archive:
archive.writestr("desc.txt", "1 1 1\np 1 0 part0\n")
archive.writestr("part0/frame.png", b"dark-frame")
PY
_resolve_boot_animation_payloads() {
BOOT_ANIMATION_LIGHT_PAYLOAD="${TEST_ROOT}/custom/boot-animation/bootanimation.zip"
BOOT_ANIMATION_DARK_PAYLOAD="${TEST_ROOT}/custom/boot-animation/bootanimation-dark.zip"
}
local dark_boot_changed
dark_boot_changed="$(fingerprint)"
[[ "${dark_boot_changed}" != "${second_boot_changed}" ]] ||
fail "dark boot-animation payload did not change the fingerprint"
)

test_output_filename_contains_fingerprint() (
Expand Down
Loading