From e1496a2abb9faee7e3e69989c7a29088e81345ea Mon Sep 17 00:00:00 2001 From: 0cwa Date: Sat, 18 Jul 2026 04:45:00 +0200 Subject: [PATCH 01/18] Add configurable patch module selection --- src/declarations.sh | 15 +- src/util_functions.sh | 50 ++++-- src/verifier.sh | 6 +- tests/declarations_config_test.sh | 53 +++++++ tests/module_selection_test.sh | 247 ++++++++++++++++++++++++++++++ tests/verifier_test.sh | 90 +++++++++++ 6 files changed, 439 insertions(+), 22 deletions(-) create mode 100644 tests/declarations_config_test.sh create mode 100644 tests/module_selection_test.sh create mode 100644 tests/verifier_test.sh diff --git a/src/declarations.sh b/src/declarations.sh index 878a17e6..156d31d2 100755 --- a/src/declarations.sh +++ b/src/declarations.sh @@ -68,11 +68,16 @@ GRAPHENEOS[OTA_TARGET]="${GRAPHENEOS[OTA_TARGET]:-}" # Will be const # Modules ADDITIONALS[AFSR]="${ADDITIONALS[AFSR]:-true}" # Android File system repack -ADDITIONALS[ALTERINSTALLER]="${ADDITIONALS[ALTERINSTALLER]:-true}" # Spoof Android package manager installer fields -ADDITIONALS[BCR]="${ADDITIONALS[BCR]:-true}" # Basic Call Recorder -ADDITIONALS[CUSTOTA]="${ADDITIONALS[CUSTOTA]:-true}" # Custom OTA Updater app -ADDITIONALS[MSD]="${ADDITIONALS[MSD]:-true}" # Mass Storage Device on USB -ADDITIONALS[OEMUNLOCKONBOOT]="${ADDITIONALS[OEMUNLOCKONBOOT]:-true}" # toggle OEM unlock button on boot +# Spoof Android package manager installer fields +ADDITIONALS[ALTERINSTALLER]="${ADDITIONALS_ALTERINSTALLER:-${ADDITIONALS[ALTERINSTALLER]:-true}}" +# Basic Call Recorder +ADDITIONALS[BCR]="${ADDITIONALS_BCR:-${ADDITIONALS[BCR]:-true}}" +# Custom OTA Updater app +ADDITIONALS[CUSTOTA]="${ADDITIONALS_CUSTOTA:-${ADDITIONALS[CUSTOTA]:-true}}" +# Mass Storage Device on USB +ADDITIONALS[MSD]="${ADDITIONALS_MSD:-${ADDITIONALS[MSD]:-true}}" +# Toggle OEM unlock button on boot +ADDITIONALS[OEMUNLOCKONBOOT]="${ADDITIONALS_OEMUNLOCKONBOOT:-${ADDITIONALS[OEMUNLOCKONBOOT]:-true}}" # Tools ADDITIONALS[AVBROOT]="${ADDITIONALS[AVBROOT]:-true}" # Android Verified Boot Root ADDITIONALS[CUSTOTA_TOOL]="${ADDITIONALS[CUSTOTA_TOOL]:-true}" # Custom OTA Tool diff --git a/src/util_functions.sh b/src/util_functions.sh index 28c5b0b8..9fe35233 100755 --- a/src/util_functions.sh +++ b/src/util_functions.sh @@ -99,6 +99,39 @@ function flag_check() { fi } +# Append enabled my-avbroot-setup modules while preserving the historical +# argument order: all module archives first, followed by their signatures. +function append_enabled_module_arguments() { + local args_name="${1}" + local -n args_ref="${args_name}" + local entry module flag + local -a enabled_modules=() + local -a module_entries=( + "custota:CUSTOTA" + "msd:MSD" + "bcr:BCR" + "oemunlockonboot:OEMUNLOCKONBOOT" + "alterinstaller:ALTERINSTALLER" + ) + + for entry in "${module_entries[@]}"; do + module="${entry%%:*}" + flag="${entry#*:}" + + if [[ "${ADDITIONALS[${flag}]}" == 'true' ]]; then + enabled_modules+=("${module}") + fi + done + + for module in "${enabled_modules[@]}"; do + args_ref+=("--module-${module}" "${WORKDIR}/modules/${module}.zip") + done + + for module in "${enabled_modules[@]}"; do + args_ref+=("--module-${module}-sig" "${WORKDIR}/signatures/${module}.zip.sig") + done +} + # Function to create and make the release called by main script function create_and_make_release() { if [[ ! -d $WORKDIR ]]; then @@ -205,8 +238,6 @@ function patch_ota() { extract_official_keys fi - # At present, the script lacks the ability to disable certain modules. - # Everything is hardcoded to be enabled by default. if ls "${ota_zip}.patched*.zip" 1>/dev/null 2>&1; then echo -e "File ${ota_zip}.pathed.zip already exists in local. Patch skipped." else @@ -230,19 +261,8 @@ function patch_ota() { args+=("--pass-avb-env-var" "PASSPHRASE_AVB") args+=("--pass-ota-env-var" "PASSPHRASE_OTA") - # Modules - args+=("--module-custota" "${WORKDIR}/modules/custota.zip") - args+=("--module-msd" "${WORKDIR}/modules/msd.zip") - args+=("--module-bcr" "${WORKDIR}/modules/bcr.zip") - args+=("--module-oemunlockonboot" "${WORKDIR}/modules/oemunlockonboot.zip") - args+=("--module-alterinstaller" "${WORKDIR}/modules/alterinstaller.zip") - - # Module signatures - args+=("--module-custota-sig" "${WORKDIR}/signatures/custota.zip.sig") - args+=("--module-msd-sig" "${WORKDIR}/signatures/msd.zip.sig") - args+=("--module-bcr-sig" "${WORKDIR}/signatures/bcr.zip.sig") - args+=("--module-oemunlockonboot-sig" "${WORKDIR}/signatures/oemunlockonboot.zip.sig") - args+=("--module-alterinstaller-sig" "${WORKDIR}/signatures/alterinstaller.zip.sig") + # Modules and their signatures + append_enabled_module_arguments args # Add debug module if unauthorized ADB is enabled if [[ "${ADDITIONALS[DEBUG]}" == 'true' ]]; then diff --git a/src/verifier.sh b/src/verifier.sh index 5267cee0..8698b3f5 100755 --- a/src/verifier.sh +++ b/src/verifier.sh @@ -64,8 +64,10 @@ function verify_downloads() { return $? fi - # Check if signatures are present for all downloaded files except for `my-avbroot-setup` and `magisk` - if [[ ! -n "$(ls "${WORKDIR}/signatures/"*.sig 2>/dev/null)" && "${tool}" != "my-avbrot-setup" && "${tool}" != "magisk" ]]; then + # Require the matching signature for every downloaded artifact except for + # `my-avbroot-setup` and `magisk`. + if [[ "${tool}" != "my-avbroot-setup" && "${tool}" != "magisk" && + ! -f "${WORKDIR}/signatures/${tool}.zip.sig" ]]; then echo -e "Error: Signature for \`${tool}\` not found in \`${WORKDIR}/signatures\`\n" auto_retry_check return $? diff --git a/tests/declarations_config_test.sh b/tests/declarations_config_test.sh new file mode 100644 index 00000000..87edb35e --- /dev/null +++ b/tests/declarations_config_test.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2024-2026 PixeneOS contributors + +set -eo pipefail + +fail() { + echo "$*" >&2 + exit 1 +} + +assert_module_config() { + local module="${1}" + local scalar="${2}" + local actual + + actual="$( + env -u "${scalar}" bash -c ' + source src/declarations.sh + printf "%s" "${ADDITIONALS[$1]}" + ' _ "${module}" + )" + [[ "${actual}" == "true" ]] || + fail "${module}: expected default true, got ${actual}" + + actual="$( + env "${scalar}=false" bash -c ' + source src/declarations.sh + printf "%s" "${ADDITIONALS[$1]}" + ' _ "${module}" + )" + [[ "${actual}" == "false" ]] || + fail "${module}: expected ${scalar}=false override, got ${actual}" + + actual="$( + env -u "${scalar}" bash -c ' + declare -A ADDITIONALS + ADDITIONALS[$1]=false + source src/declarations.sh + printf "%s" "${ADDITIONALS[$1]}" + ' _ "${module}" + )" + [[ "${actual}" == "false" ]] || + fail "${module}: expected associative-array fallback false, got ${actual}" +} + +assert_module_config ALTERINSTALLER ADDITIONALS_ALTERINSTALLER +assert_module_config BCR ADDITIONALS_BCR +assert_module_config CUSTOTA ADDITIONALS_CUSTOTA +assert_module_config MSD ADDITIONALS_MSD +assert_module_config OEMUNLOCKONBOOT ADDITIONALS_OEMUNLOCKONBOOT + +echo "declarations configuration tests passed" diff --git a/tests/module_selection_test.sh b/tests/module_selection_test.sh new file mode 100644 index 00000000..3716bf3a --- /dev/null +++ b/tests/module_selection_test.sh @@ -0,0 +1,247 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2024-2026 PixeneOS contributors + +set -eo pipefail + +source src/util_functions.sh + +set -u + +TEST_ROOT="$(mktemp -d)" +trap 'rm -rf "${TEST_ROOT}"' EXIT + +declare -a CAPTURED_ARGS=() +declare -a EXPECTED_ARGS=() + +python() { + CAPTURED_ARGS=("$@") +} + +deactivate() { + : +} + +setup_debug_module() { + : +} + +fail() { + echo "$*" >&2 + exit 1 +} + +assert_array_equals() { + local expected_name="${1}" + local actual_name="${2}" + local context="${3}" + local -n expected_ref="${expected_name}" + local -n actual_ref="${actual_name}" + local index + + if [[ "${#expected_ref[@]}" -ne "${#actual_ref[@]}" ]]; then + printf '%s: expected %d arguments, got %d\n' \ + "${context}" "${#expected_ref[@]}" "${#actual_ref[@]}" >&2 + printf 'Expected: %q\n' "${expected_ref[@]}" >&2 + printf 'Actual: %q\n' "${actual_ref[@]}" >&2 + exit 1 + fi + + for index in "${!expected_ref[@]}"; do + if [[ "${expected_ref[${index}]}" != "${actual_ref[${index}]}" ]]; then + printf '%s: argument %d differs: expected %q, got %q\n' \ + "${context}" "${index}" "${expected_ref[${index}]}" "${actual_ref[${index}]}" >&2 + exit 1 + fi + done +} + +assert_contains() { + local expected="${1}" + local context="${2}" + local value + + for value in "${CAPTURED_ARGS[@]}"; do + [[ "${value}" == "${expected}" ]] && return 0 + done + + fail "${context}: expected argument not found: ${expected}" +} + +assert_not_contains() { + local unexpected="${1}" + local context="${2}" + local value + + for value in "${CAPTURED_ARGS[@]}"; do + if [[ "${value}" == "${unexpected}" ]]; then + fail "${context}: unexpected argument found: ${unexpected}" + fi + done +} + +reset_fixture() { + local case_name="${1}" + + WORKDIR="${TEST_ROOT}/${case_name}" + mkdir -p \ + "${WORKDIR}/extracted/extracts" \ + "${WORKDIR}/extracted/ota/META-INF/com/android" \ + "${WORKDIR}/tools/my-avbroot-setup" + touch \ + "${WORKDIR}/extracted/avb_pkmd.bin" \ + "${WORKDIR}/extracted/ota/META-INF/com/android/otacert" + + GRAPHENEOS[OTA_TARGET]="fixture-ota" + OUTPUTS[PATCHED_OTA]="${WORKDIR}/patched.zip" + KEYS[AVB]="${WORKDIR}/keys/avb.key" + KEYS[OTA]="${WORKDIR}/keys/ota.key" + KEYS[CERT_OTA]="${WORKDIR}/keys/ota.crt" + KEYS[PKMD]="${WORKDIR}/keys/avb_pkmd.bin" + MAGISK[PREINIT]="sda10" + INTERACTIVE_MODE="true" + VIRTUAL_ENV="fixture" + + ADDITIONALS[CUSTOTA]="true" + ADDITIONALS[MSD]="true" + ADDITIONALS[BCR]="true" + ADDITIONALS[OEMUNLOCKONBOOT]="true" + ADDITIONALS[ALTERINSTALLER]="true" + ADDITIONALS[DEBUG]="false" + ADDITIONALS[MAS_COMPATIBLE_SEPOLICY]="false" + ADDITIONALS[ROOT]="false" + + CAPTURED_ARGS=() +} + +set_default_expected_args() { + EXPECTED_ARGS=( + "${WORKDIR}/tools/my-avbroot-setup/patch.py" + "--input" "${WORKDIR}/fixture-ota.zip" + "--output" "${WORKDIR}/patched.zip" + "--verify-public-key-avb" "${WORKDIR}/extracted/avb_pkmd.bin" + "--verify-cert-ota" "${WORKDIR}/extracted/ota/META-INF/com/android/otacert" + "--sign-key-avb" "${WORKDIR}/keys/avb.key" + "--sign-key-ota" "${WORKDIR}/keys/ota.key" + "--sign-cert-ota" "${WORKDIR}/keys/ota.crt" + "--pass-avb-env-var" "PASSPHRASE_AVB" + "--pass-ota-env-var" "PASSPHRASE_OTA" + "--module-custota" "${WORKDIR}/modules/custota.zip" + "--module-msd" "${WORKDIR}/modules/msd.zip" + "--module-bcr" "${WORKDIR}/modules/bcr.zip" + "--module-oemunlockonboot" "${WORKDIR}/modules/oemunlockonboot.zip" + "--module-alterinstaller" "${WORKDIR}/modules/alterinstaller.zip" + "--module-custota-sig" "${WORKDIR}/signatures/custota.zip.sig" + "--module-msd-sig" "${WORKDIR}/signatures/msd.zip.sig" + "--module-bcr-sig" "${WORKDIR}/signatures/bcr.zip.sig" + "--module-oemunlockonboot-sig" "${WORKDIR}/signatures/oemunlockonboot.zip.sig" + "--module-alterinstaller-sig" "${WORKDIR}/signatures/alterinstaller.zip.sig" + "--patch-arg=--rootless" + ) +} + +remove_expected_modules() { + local module + local value + local remove + local -a filtered=() + local -a modules=("$@") + + for value in "${EXPECTED_ARGS[@]}"; do + remove="false" + for module in "${modules[@]}"; do + if [[ "${value}" == "--module-${module}" || + "${value}" == "${WORKDIR}/modules/${module}.zip" || + "${value}" == "--module-${module}-sig" || + "${value}" == "${WORKDIR}/signatures/${module}.zip.sig" ]]; then + remove="true" + break + fi + done + + [[ "${remove}" == "false" ]] && filtered+=("${value}") + done + + EXPECTED_ARGS=("${filtered[@]}") +} + +run_patch() { + patch_ota >/dev/null +} + +test_default_arguments() { + reset_fixture default + set_default_expected_args + run_patch + assert_array_equals EXPECTED_ARGS CAPTURED_ARGS "default module selection" +} + +test_each_module_can_be_disabled() { + local entry module flag + local -a entries=( + "custota:CUSTOTA" + "msd:MSD" + "bcr:BCR" + "oemunlockonboot:OEMUNLOCKONBOOT" + "alterinstaller:ALTERINSTALLER" + ) + + for entry in "${entries[@]}"; do + module="${entry%%:*}" + flag="${entry#*:}" + reset_fixture "disable-${module}" + ADDITIONALS[${flag}]="false" + set_default_expected_args + remove_expected_modules "${module}" + run_patch + assert_array_equals EXPECTED_ARGS CAPTURED_ARGS "disable ${module}" + done +} + +test_all_modules_can_be_disabled() { + local flag + local -a modules=(custota msd bcr oemunlockonboot alterinstaller) + local -a flags=(CUSTOTA MSD BCR OEMUNLOCKONBOOT ALTERINSTALLER) + + reset_fixture disable-all + for flag in "${flags[@]}"; do + ADDITIONALS[${flag}]="false" + done + set_default_expected_args + remove_expected_modules "${modules[@]}" + run_patch + assert_array_equals EXPECTED_ARGS CAPTURED_ARGS "disable all modules" +} + +test_special_cases_remain_available() { + reset_fixture compatible-sepolicy + ADDITIONALS[MAS_COMPATIBLE_SEPOLICY]="true" + run_patch + assert_contains "--compatible-sepolicy" "compatible SELinux" + assert_contains "--patch-arg=--rootless" "compatible SELinux" + + reset_fixture root + ADDITIONALS[ROOT]="true" + run_patch + assert_contains "--patch-arg=--magisk" "root" + assert_contains "${WORKDIR}/modules/magisk.apk" "root" + assert_contains "--patch-arg=--magisk-preinit-device" "root" + assert_contains "sda10" "root" + assert_not_contains "--patch-arg=--rootless" "root" + + reset_fixture debug + ADDITIONALS[DEBUG]="true" + run_patch + assert_contains "--module-debug" "debug" + assert_contains "${WORKDIR}/modules/dummy.zip" "debug" + assert_contains "--module-debug-sig" "debug" + assert_contains "${WORKDIR}/modules/dummy.zip.sig" "debug" + assert_contains "--patch-arg=--rootless" "debug" +} + +test_default_arguments +test_each_module_can_be_disabled +test_all_modules_can_be_disabled +test_special_cases_remain_available + +echo "module selection tests passed" diff --git a/tests/verifier_test.sh b/tests/verifier_test.sh new file mode 100644 index 00000000..418a7757 --- /dev/null +++ b/tests/verifier_test.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2024-2026 PixeneOS contributors + +set -eo pipefail + +source src/verifier.sh + +set -u + +TEST_ROOT="$(mktemp -d)" +trap 'rm -rf "${TEST_ROOT}"' EXIT + +RETRY_CALLED="false" + +auto_retry_check() { + RETRY_CALLED="true" + return 23 +} + +fail() { + echo "$*" >&2 + exit 1 +} + +reset_fixture() { + local case_name="${1}" + + WORKDIR="${TEST_ROOT}/${case_name}" + mkdir -p "${WORKDIR}/modules" "${WORKDIR}/signatures" "${WORKDIR}/tools" + RETRY_CALLED="false" +} + +assert_retry_failure() { + local tool="${1}" + local context="${2}" + local status + + if verify_downloads "${tool}" >/dev/null; then + fail "${context}: verification unexpectedly succeeded" + else + status=$? + fi + + [[ "${status}" -eq 23 ]] || + fail "${context}: expected mocked retry status 23, got ${status}" + [[ "${RETRY_CALLED}" == "true" ]] || + fail "${context}: expected retry handler to be called" +} + +test_matching_signature_succeeds() { + reset_fixture matching-signature + touch "${WORKDIR}/modules/bcr.zip" "${WORKDIR}/signatures/bcr.zip.sig" + + verify_downloads bcr >/dev/null + + [[ "${RETRY_CALLED}" == "false" ]] || + fail "matching signature: retry handler should not be called" +} + +test_missing_signature_fails() { + reset_fixture missing-signature + touch "${WORKDIR}/modules/bcr.zip" + + assert_retry_failure bcr "missing signature" +} + +test_wrong_module_signature_fails() { + reset_fixture wrong-signature + touch "${WORKDIR}/modules/bcr.zip" "${WORKDIR}/signatures/msd.zip.sig" + + assert_retry_failure bcr "wrong-module signature" +} + +test_unsigned_exceptions_succeed() { + reset_fixture unsigned-helper + mkdir -p "${WORKDIR}/tools/my-avbroot-setup" + verify_downloads my-avbroot-setup >/dev/null + + reset_fixture unsigned-magisk + touch "${WORKDIR}/modules/magisk.apk" + verify_downloads magisk >/dev/null +} + +test_matching_signature_succeeds +test_missing_signature_fails +test_wrong_module_signature_fails +test_unsigned_exceptions_succeed + +echo "verifier tests passed" From d12bcb1eaad77d6dfd6b52d64272e15d8eaf4c6f Mon Sep 17 00:00:00 2001 From: Pa1NarK <69745008+pixincreate@users.noreply.github.com> Date: Sat, 18 Jul 2026 15:36:22 +0530 Subject: [PATCH 02/18] chore(deps): update dependency chenxiaolong/custota to v6.3 (#320) Co-authored-by: Renovate Bot --- src/declarations.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/declarations.sh b/src/declarations.sh index 67f57086..5e2508ad 100755 --- a/src/declarations.sh +++ b/src/declarations.sh @@ -31,7 +31,7 @@ VERSION[ALTERINSTALLER]="${VERSION[ALTERINSTALLER]:-2.4}" VERSION[AVBROOT]="${VERSION[AVBROOT]:-3.31.0}" VERSION[AVBROOT_SETUP]="e4f80bb54aa5ae8de6109edd7d0873d5b4966748" # Commit hash VERSION[BCR]="${VERSION[BCR]:-3.4}" -VERSION[CUSTOTA]="${VERSION[CUSTOTA]:-6.2}" +VERSION[CUSTOTA]="${VERSION[CUSTOTA]:-6.3}" VERSION[GRAPHENEOS]="${VERSION[GRAPHENEOS]:-}" VERSION[MAGISK]="${VERSION[MAGISK]:-}" VERSION[MSD]="${VERSION[MSD]:-2.3}" From bfcd44f4f4964290ff6af4e5e294a8f04d7aecf6 Mon Sep 17 00:00:00 2001 From: Pa1NarK <69745008+pixincreate@users.noreply.github.com> Date: Sat, 18 Jul 2026 15:36:32 +0530 Subject: [PATCH 03/18] chore(deps): update dependency chenxiaolong/avbroot to v3.32.0 (#319) Co-authored-by: Renovate Bot --- src/declarations.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/declarations.sh b/src/declarations.sh index 5e2508ad..2e9d0e2d 100755 --- a/src/declarations.sh +++ b/src/declarations.sh @@ -28,7 +28,7 @@ USER="pixincreate" # GitHub username # Application version variables VERSION[AFSR]="${VERSION[AFSR]:-1.0.4}" VERSION[ALTERINSTALLER]="${VERSION[ALTERINSTALLER]:-2.4}" -VERSION[AVBROOT]="${VERSION[AVBROOT]:-3.31.0}" +VERSION[AVBROOT]="${VERSION[AVBROOT]:-3.32.0}" VERSION[AVBROOT_SETUP]="e4f80bb54aa5ae8de6109edd7d0873d5b4966748" # Commit hash VERSION[BCR]="${VERSION[BCR]:-3.4}" VERSION[CUSTOTA]="${VERSION[CUSTOTA]:-6.3}" From 3a3288eb82c065c07deee5be3208d813f1969e88 Mon Sep 17 00:00:00 2001 From: 0cwa Date: Sat, 18 Jul 2026 14:39:08 +0200 Subject: [PATCH 04/18] Add default-off F-Droid locked module wiring --- docs/fdroid-privileged-extension.md | 28 +++++ src/declarations.sh | 9 +- src/util_functions.sh | 73 ++++++++++- src/verifier.sh | 54 +++++++++ tests/declarations_config_test.sh | 38 ++++++ tests/module_selection_test.sh | 180 +++++++++++++++++++++++++++- tests/verifier_test.sh | 84 +++++++++++++ 7 files changed, 459 insertions(+), 7 deletions(-) create mode 100644 docs/fdroid-privileged-extension.md diff --git a/docs/fdroid-privileged-extension.md b/docs/fdroid-privileged-extension.md new file mode 100644 index 00000000..ee8040d1 --- /dev/null +++ b/docs/fdroid-privileged-extension.md @@ -0,0 +1,28 @@ +# F-Droid Privileged Extension locked-module wiring + +The F-Droid Privileged Extension integration is disabled by default. PixeneOS +does not currently ship an approved production artifact lock or ROM profile, +so enabling the flag without adding reviewed inputs fails closed. + +After an artifact lock and profile have been independently reviewed and +committed to this repository, the local configuration surface is: + +```toml +ADDITIONALS_FDROID_PRIVILEGED_EXTENSION = true +FDROID_PRIVILEGED_EXTENSION_LOCK = "path/to/artifacts.lock.json" +FDROID_PRIVILEGED_EXTENSION_PROFILE = "path/to/profile.toml" +``` + +The lock and profile must be regular, non-symlink files inside the checkout, +tracked by Git, and byte-identical to their versions in `HEAD`. Optional +`FDROID_PRIVILEGED_EXTENSION_CACHE` and +`FDROID_PRIVILEGED_EXTENSION_PATCH_REPORT` values select local cache and report +paths. The cache defaults under `.tmp`; the report defaults next to the patched +OTA as `.patch-report.json` so ordinary work-directory cleanup +does not discard the audit record. + +When enabled, PixeneOS asks the pinned helper to resolve the profile before any +network fetch, fetch only the locked module artifacts, verify them, and finally +passes the lock, profile, cache, and report paths to the patch command. Artifact +URLs and versions are never declared in Bash, and the module does not use the +legacy ZIP/signature preflight. diff --git a/src/declarations.sh b/src/declarations.sh index 156d31d2..62692756 100755 --- a/src/declarations.sh +++ b/src/declarations.sh @@ -35,7 +35,7 @@ PIXENEOS_AVBROOT_SETUP_SOURCE="${PIXENEOS_AVBROOT_SETUP_SOURCE:-}" VERSION[AFSR]="${VERSION[AFSR]:-1.0.4}" VERSION[ALTERINSTALLER]="${VERSION[ALTERINSTALLER]:-2.4}" VERSION[AVBROOT]="${VERSION[AVBROOT]:-3.31.0}" -VERSION[AVBROOT_SETUP]="a14c242a89abb1a13b8c7474dd8235ee75fd31d6" # Commit hash +VERSION[AVBROOT_SETUP]="09d32371829fb3b34455edbd2fee58fd84db613c" # Commit hash VERSION[BCR]="${VERSION[BCR]:-3.4}" VERSION[CUSTOTA]="${VERSION[CUSTOTA]:-6.2}" VERSION[GRAPHENEOS]="${VERSION[GRAPHENEOS]:-}" @@ -78,6 +78,13 @@ ADDITIONALS[CUSTOTA]="${ADDITIONALS_CUSTOTA:-${ADDITIONALS[CUSTOTA]:-true}}" ADDITIONALS[MSD]="${ADDITIONALS_MSD:-${ADDITIONALS[MSD]:-true}}" # Toggle OEM unlock button on boot ADDITIONALS[OEMUNLOCKONBOOT]="${ADDITIONALS_OEMUNLOCKONBOOT:-${ADDITIONALS[OEMUNLOCKONBOOT]:-true}}" +# F-Droid client and Privileged Extension through the locked native adapter. +# There is intentionally no production lock/profile default yet. +ADDITIONALS[FDROID_PRIVILEGED_EXTENSION]="${ADDITIONALS_FDROID_PRIVILEGED_EXTENSION:-${ADDITIONALS[FDROID_PRIVILEGED_EXTENSION]:-false}}" +FDROID_PRIVILEGED_EXTENSION_LOCK="${FDROID_PRIVILEGED_EXTENSION_LOCK:-}" +FDROID_PRIVILEGED_EXTENSION_PROFILE="${FDROID_PRIVILEGED_EXTENSION_PROFILE:-}" +FDROID_PRIVILEGED_EXTENSION_CACHE="${FDROID_PRIVILEGED_EXTENSION_CACHE:-}" +FDROID_PRIVILEGED_EXTENSION_PATCH_REPORT="${FDROID_PRIVILEGED_EXTENSION_PATCH_REPORT:-}" # Tools ADDITIONALS[AVBROOT]="${ADDITIONALS[AVBROOT]:-true}" # Android Verified Boot Root ADDITIONALS[CUSTOTA_TOOL]="${ADDITIONALS[CUSTOTA_TOOL]:-true}" # Custom OTA Tool diff --git a/src/util_functions.sh b/src/util_functions.sh index 9fe35233..4d25474f 100755 --- a/src/util_functions.sh +++ b/src/util_functions.sh @@ -132,6 +132,61 @@ function append_enabled_module_arguments() { done } +# Resolve and acquire the locked F-Droid inputs before exposing them to the +# patch command. Artifact URLs and versions belong exclusively to the lock. +function prepare_fdroid_privileged_extension() { + local args_name="${1}" + local helper_root="${2}" + local -n args_ref="${args_name}" + local lock_path="${FDROID_PRIVILEGED_EXTENSION_LOCK}" + local profile_path="${FDROID_PRIVILEGED_EXTENSION_PROFILE}" + local cache_path="${FDROID_PRIVILEGED_EXTENSION_CACHE:-${WORKDIR}/locked-artifacts}" + local report_path="${FDROID_PRIVILEGED_EXTENSION_PATCH_REPORT:-${OUTPUTS[PATCHED_OTA]}.patch-report.json}" + local module_tool="${helper_root}/module-tool.py" + + if [[ "${ADDITIONALS[FDROID_PRIVILEGED_EXTENSION]}" != 'true' ]]; then + return 0 + fi + + if ! verify_fdroid_privileged_extension_inputs \ + "${lock_path}" "${profile_path}"; then + return 1 + fi + if [[ ! -f "${module_tool}" || -L "${module_tool}" ]]; then + echo "Error: the pinned patch helper lacks the locked module tool." >&2 + return 1 + fi + + if ! python "${module_tool}" resolve \ + --profile "${profile_path}" \ + --lock "${lock_path}" \ + --format json >/dev/null; then + echo "Error: F-Droid locked profile resolution failed." >&2 + return 1 + fi + if ! python "${module_tool}" artifacts fetch \ + --lock "${lock_path}" \ + --cache "${cache_path}" \ + --module fdroid-privileged-extension >/dev/null; then + echo "Error: F-Droid locked artifact fetch failed." >&2 + return 1 + fi + if ! python "${module_tool}" artifacts verify \ + --lock "${lock_path}" \ + --cache "${cache_path}" \ + --module fdroid-privileged-extension >/dev/null; then + echo "Error: F-Droid locked artifact verification failed." >&2 + return 1 + fi + + args_ref+=( + "--module-lock" "${lock_path}" + "--module-profile" "${profile_path}" + "--module-cache" "${cache_path}" + "--patch-report" "${report_path}" + ) +} + # Function to create and make the release called by main script function create_and_make_release() { if [[ ! -d $WORKDIR ]]; then @@ -238,7 +293,10 @@ function patch_ota() { extract_official_keys fi - if ls "${ota_zip}.patched*.zip" 1>/dev/null 2>&1; then + # Legacy output markers do not encode a locked module selection. Never reuse + # one for an enabled F-Droid build. + if [[ "${ADDITIONALS[FDROID_PRIVILEGED_EXTENSION]}" != 'true' ]] && + ls "${ota_zip}.patched*.zip" 1>/dev/null 2>&1; then echo -e "File ${ota_zip}.pathed.zip already exists in local. Patch skipped." else echo -e "Patching OTA..." @@ -261,8 +319,16 @@ function patch_ota() { args+=("--pass-avb-env-var" "PASSPHRASE_AVB") args+=("--pass-ota-env-var" "PASSPHRASE_OTA") + # Clear the extraction scratch tree before locked acquisition. A + # caller-selected cache below this tree must not be verified and then + # deleted before patch.py consumes it. + rm -rf -- "${WORKDIR}/extracted/extracts/" + # Modules and their signatures append_enabled_module_arguments args + if ! prepare_fdroid_privileged_extension args "${my_avbroot_setup}"; then + return 1 + fi # Add debug module if unauthorized ADB is enabled if [[ "${ADDITIONALS[DEBUG]}" == 'true' ]]; then @@ -292,11 +358,8 @@ function patch_ota() { echo -e "Magisk is not enabled. Skipping...\n" fi - # Have to clear storage space because, `csig` results in storage runout - rm -rf ${WORKDIR}/extracted/extracts/ - # Python command to run the patch script - python ${my_avbroot_setup}/patch.py "${args[@]}" + python "${my_avbroot_setup}/patch.py" "${args[@]}" fi # Deactivate the virtual environment after patching the OTA diff --git a/src/verifier.sh b/src/verifier.sh index 8698b3f5..eadd8a77 100755 --- a/src/verifier.sh +++ b/src/verifier.sh @@ -13,6 +13,60 @@ source src/declarations.sh RETRY_COUNT=0 MAX_RETRIES=3 +# Require lock/profile inputs to be regular, non-symlink files whose exact +# contents are already committed in the current PixeneOS checkout. +function verify_checked_in_locked_input() { + local input_path="${1}" + local repository_root resolved_path relative_path + local committed_object working_object + + [[ -n "${input_path}" && -f "${input_path}" && ! -L "${input_path}" ]] || + return 1 + + repository_root="$(git rev-parse --show-toplevel 2>/dev/null)" || return 1 + resolved_path="$(realpath -e -- "${input_path}" 2>/dev/null)" || return 1 + + case "${resolved_path}" in + "${repository_root}"/*) + relative_path="${resolved_path#"${repository_root}"/}" + ;; + *) + return 1 + ;; + esac + + git -C "${repository_root}" ls-files --error-unmatch -- \ + "${relative_path}" >/dev/null 2>&1 || return 1 + git -C "${repository_root}" diff --quiet --no-ext-diff -- \ + "${relative_path}" || return 1 + git -C "${repository_root}" diff --cached --quiet --no-ext-diff -- \ + "${relative_path}" || return 1 + committed_object="$( + git -C "${repository_root}" rev-parse --verify \ + "HEAD:${relative_path}" 2>/dev/null + )" || return 1 + working_object="$( + git -C "${repository_root}" hash-object -- "${resolved_path}" 2>/dev/null + )" || return 1 + [[ "${working_object}" == "${committed_object}" ]] +} + +function verify_fdroid_privileged_extension_inputs() { + local lock_path="${1}" + local profile_path="${2}" + + if [[ -z "${lock_path}" || -z "${profile_path}" ]]; then + echo "Error: F-Droid locked mode requires an explicit lock and profile." >&2 + return 1 + fi + + if ! verify_checked_in_locked_input "${lock_path}" || + ! verify_checked_in_locked_input "${profile_path}"; then + echo "Error: F-Droid lock and profile must be clean checked-in regular files." >&2 + return 1 + fi +} + # Function to look after number of times a retry has been made if the auto retry flag is enabled function auto_retry_check() { if [[ "${ADDITIONALS[RETRY]}" == "true" ]]; then diff --git a/tests/declarations_config_test.sh b/tests/declarations_config_test.sh index 87edb35e..e2cdb6ae 100644 --- a/tests/declarations_config_test.sh +++ b/tests/declarations_config_test.sh @@ -44,10 +44,48 @@ assert_module_config() { fail "${module}: expected associative-array fallback false, got ${actual}" } +assert_default_off_module_config() { + local module="${1}" + local scalar="${2}" + local actual + + actual="$( + env -u "${scalar}" bash -c ' + source src/declarations.sh + printf "%s" "${ADDITIONALS[$1]}" + ' _ "${module}" + )" + [[ "${actual}" == "false" ]] || + fail "${module}: expected default false, got ${actual}" + + actual="$( + env "${scalar}=true" bash -c ' + source src/declarations.sh + printf "%s" "${ADDITIONALS[$1]}" + ' _ "${module}" + )" + [[ "${actual}" == "true" ]] || + fail "${module}: expected ${scalar}=true override, got ${actual}" + + actual="$( + env -u "${scalar}" bash -c ' + declare -A ADDITIONALS + ADDITIONALS[$1]=true + source src/declarations.sh + printf "%s" "${ADDITIONALS[$1]}" + ' _ "${module}" + )" + [[ "${actual}" == "true" ]] || + fail "${module}: expected associative-array fallback true, got ${actual}" +} + assert_module_config ALTERINSTALLER ADDITIONALS_ALTERINSTALLER assert_module_config BCR ADDITIONALS_BCR assert_module_config CUSTOTA ADDITIONALS_CUSTOTA assert_module_config MSD ADDITIONALS_MSD assert_module_config OEMUNLOCKONBOOT ADDITIONALS_OEMUNLOCKONBOOT +assert_default_off_module_config \ + FDROID_PRIVILEGED_EXTENSION \ + ADDITIONALS_FDROID_PRIVILEGED_EXTENSION echo "declarations configuration tests passed" diff --git a/tests/module_selection_test.sh b/tests/module_selection_test.sh index 3716bf3a..923f8abc 100644 --- a/tests/module_selection_test.sh +++ b/tests/module_selection_test.sh @@ -13,11 +13,37 @@ trap 'rm -rf "${TEST_ROOT}"' EXIT declare -a CAPTURED_ARGS=() declare -a EXPECTED_ARGS=() +declare -a PREPARE_STAGES=() +PREPARE_FAILURE="" +PREPARE_CACHE_SENTINEL="" +LOCKED_INPUTS_VALID="true" python() { + local stage + + if [[ "${1}" == */module-tool.py ]]; then + if [[ "${2}" == "artifacts" ]]; then + stage="artifacts-${3}" + else + stage="${2}" + fi + PREPARE_STAGES+=("${stage}") + if [[ "${stage}" == "artifacts-fetch" && + -n "${PREPARE_CACHE_SENTINEL}" ]]; then + mkdir -p "$(dirname -- "${PREPARE_CACHE_SENTINEL}")" + touch -- "${PREPARE_CACHE_SENTINEL}" + fi + [[ "${stage}" != "${PREPARE_FAILURE}" ]] + return + fi + CAPTURED_ARGS=("$@") } +verify_checked_in_locked_input() { + [[ "${LOCKED_INPUTS_VALID}" == "true" ]] +} + deactivate() { : } @@ -80,6 +106,36 @@ assert_not_contains() { done } +assert_pair() { + local option="${1}" + local expected_value="${2}" + local context="${3}" + local index + + for index in "${!CAPTURED_ARGS[@]}"; do + if [[ "${CAPTURED_ARGS[${index}]}" == "${option}" ]]; then + [[ "${CAPTURED_ARGS[$((index + 1))]:-}" == "${expected_value}" ]] || + fail "${context}: ${option} has the wrong value" + return 0 + fi + done + fail "${context}: missing ${option}" +} + +assert_prepare_stages() { + local context="${1}" + shift + local -a expected=("$@") + local index + + [[ "${#expected[@]}" -eq "${#PREPARE_STAGES[@]}" ]] || + fail "${context}: wrong preparation stage count" + for index in "${!expected[@]}"; do + [[ "${expected[${index}]}" == "${PREPARE_STAGES[${index}]}" ]] || + fail "${context}: preparation order differs" + done +} + reset_fixture() { local case_name="${1}" @@ -90,7 +146,8 @@ reset_fixture() { "${WORKDIR}/tools/my-avbroot-setup" touch \ "${WORKDIR}/extracted/avb_pkmd.bin" \ - "${WORKDIR}/extracted/ota/META-INF/com/android/otacert" + "${WORKDIR}/extracted/ota/META-INF/com/android/otacert" \ + "${WORKDIR}/tools/my-avbroot-setup/module-tool.py" GRAPHENEOS[OTA_TARGET]="fixture-ota" OUTPUTS[PATCHED_OTA]="${WORKDIR}/patched.zip" @@ -107,11 +164,21 @@ reset_fixture() { ADDITIONALS[BCR]="true" ADDITIONALS[OEMUNLOCKONBOOT]="true" ADDITIONALS[ALTERINSTALLER]="true" + ADDITIONALS[FDROID_PRIVILEGED_EXTENSION]="false" ADDITIONALS[DEBUG]="false" ADDITIONALS[MAS_COMPATIBLE_SEPOLICY]="false" ADDITIONALS[ROOT]="false" + FDROID_PRIVILEGED_EXTENSION_LOCK="" + FDROID_PRIVILEGED_EXTENSION_PROFILE="" + FDROID_PRIVILEGED_EXTENSION_CACHE="" + FDROID_PRIVILEGED_EXTENSION_PATCH_REPORT="" + LOCKED_INPUTS_VALID="true" + PREPARE_FAILURE="" + PREPARE_CACHE_SENTINEL="" + CAPTURED_ARGS=() + PREPARE_STAGES=() } set_default_expected_args() { @@ -239,9 +306,120 @@ test_special_cases_remain_available() { assert_contains "--patch-arg=--rootless" "debug" } +enable_fdroid_fixture() { + ADDITIONALS[FDROID_PRIVILEGED_EXTENSION]="true" + FDROID_PRIVILEGED_EXTENSION_LOCK="${WORKDIR}/fdroid.lock.json" + FDROID_PRIVILEGED_EXTENSION_PROFILE="${WORKDIR}/fdroid.profile.toml" + touch \ + "${FDROID_PRIVILEGED_EXTENSION_LOCK}" \ + "${FDROID_PRIVILEGED_EXTENSION_PROFILE}" +} + +test_fdroid_locked_preparation_and_arguments() { + reset_fixture fdroid-enabled + enable_fdroid_fixture + run_patch + + assert_prepare_stages \ + "F-Droid preparation" \ + resolve artifacts-fetch artifacts-verify + assert_pair \ + "--module-lock" "${FDROID_PRIVILEGED_EXTENSION_LOCK}" "F-Droid" + assert_pair \ + "--module-profile" "${FDROID_PRIVILEGED_EXTENSION_PROFILE}" "F-Droid" + assert_pair \ + "--module-cache" "${WORKDIR}/locked-artifacts" "F-Droid" + assert_pair \ + "--patch-report" "${OUTPUTS[PATCHED_OTA]}.patch-report.json" "F-Droid" + assert_not_contains \ + "--module-fdroid-privileged-extension" "F-Droid legacy archive" + assert_not_contains \ + "--module-fdroid-privileged-extension-sig" "F-Droid legacy signature" +} + +assert_patch_fails_before_execution() { + local context="${1}" + + if patch_ota >/dev/null 2>&1; then + fail "${context}: patch unexpectedly succeeded" + fi + [[ "${#CAPTURED_ARGS[@]}" -eq 0 ]] || + fail "${context}: patch command was executed" +} + +test_fdroid_missing_or_untracked_inputs_fail_closed() { + reset_fixture fdroid-missing-inputs + ADDITIONALS[FDROID_PRIVILEGED_EXTENSION]="true" + assert_patch_fails_before_execution "missing locked inputs" + assert_prepare_stages "missing locked inputs" + + reset_fixture fdroid-untracked-inputs + enable_fdroid_fixture + LOCKED_INPUTS_VALID="false" + assert_patch_fails_before_execution "untracked locked inputs" + assert_prepare_stages "untracked locked inputs" +} + +test_fdroid_preparation_stages_fail_closed() { + local failure + + for failure in resolve artifacts-fetch artifacts-verify; do + reset_fixture "fdroid-fail-${failure}" + enable_fdroid_fixture + PREPARE_FAILURE="${failure}" + assert_patch_fails_before_execution "failed ${failure}" + case "${failure}" in + resolve) + assert_prepare_stages "failed resolve" resolve + ;; + artifacts-fetch) + assert_prepare_stages \ + "failed fetch" resolve artifacts-fetch + ;; + artifacts-verify) + assert_prepare_stages \ + "failed verify" resolve artifacts-fetch artifacts-verify + ;; + esac + done +} + +test_fdroid_locked_paths_survive_cleanup_and_quoting() { + reset_fixture "fdroid paths with spaces [literal]*" + enable_fdroid_fixture + FDROID_PRIVILEGED_EXTENSION_CACHE="${WORKDIR}/extracted/extracts/cache [literal]*" + FDROID_PRIVILEGED_EXTENSION_PATCH_REPORT="${WORKDIR}/reports/patch report [literal]*.json" + PREPARE_CACHE_SENTINEL="${FDROID_PRIVILEGED_EXTENSION_CACHE}/verified-object" + + run_patch + + [[ -f "${PREPARE_CACHE_SENTINEL}" ]] || + fail "F-Droid cache was removed after locked verification" + assert_pair \ + "--module-cache" "${FDROID_PRIVILEGED_EXTENSION_CACHE}" \ + "F-Droid quoted cache" + assert_pair \ + "--patch-report" "${FDROID_PRIVILEGED_EXTENSION_PATCH_REPORT}" \ + "F-Droid quoted report" +} + +test_fdroid_enabled_does_not_reuse_legacy_output_marker() { + reset_fixture fdroid-legacy-marker + ADDITIONALS[FDROID_PRIVILEGED_EXTENSION]="true" + touch -- "${WORKDIR}/${GRAPHENEOS[OTA_TARGET]}.patched*.zip" + + assert_patch_fails_before_execution "enabled F-Droid legacy marker" + assert_prepare_stages "enabled F-Droid legacy marker" +} + test_default_arguments test_each_module_can_be_disabled test_all_modules_can_be_disabled test_special_cases_remain_available +test_fdroid_locked_preparation_and_arguments +test_fdroid_missing_or_untracked_inputs_fail_closed +test_fdroid_preparation_stages_fail_closed +test_fdroid_locked_paths_survive_cleanup_and_quoting +test_fdroid_enabled_does_not_reuse_legacy_output_marker echo "module selection tests passed" diff --git a/tests/verifier_test.sh b/tests/verifier_test.sh index 418a7757..20b62780 100644 --- a/tests/verifier_test.sh +++ b/tests/verifier_test.sh @@ -82,9 +82,93 @@ test_unsigned_exceptions_succeed() { verify_downloads magisk >/dev/null } +test_locked_inputs_require_explicit_checked_in_files() { + reset_fixture locked-inputs + local lock_path="${WORKDIR}/fdroid.lock.json" + local profile_path="${WORKDIR}/fdroid.profile.toml" + + if verify_fdroid_privileged_extension_inputs "" "" >/dev/null 2>&1; then + fail "locked inputs: empty paths unexpectedly succeeded" + fi + + touch "${lock_path}" "${profile_path}" + if verify_fdroid_privileged_extension_inputs \ + "${lock_path}" "${profile_path}" >/dev/null 2>&1; then + fail "locked inputs: untracked temporary files unexpectedly succeeded" + fi +} + +test_locked_input_rejects_worktree_index_mode_and_path_aliases() { + reset_fixture locked-input-git-state + local repository="${WORKDIR}/repository with spaces" + local lock_path="${repository}/locks/fdroid lock.json" + local outside_path="${WORKDIR}/outside.lock" + + git init -q -- "${repository}" + git -C "${repository}" config user.email test@example.invalid + git -C "${repository}" config user.name "Pixene test" + git -C "${repository}" config core.filemode true + mkdir -p "$(dirname -- "${lock_path}")" + printf '%s\n' '{"fixture":"clean"}' >"${lock_path}" + git -C "${repository}" add -- "locks/fdroid lock.json" + git -C "${repository}" commit -q -m fixture + + ( + cd "${repository}" + verify_checked_in_locked_input "${lock_path}" + ) || fail "clean checked-in lock was rejected" + + printf '%s\n' '{"fixture":"dirty"}' >"${lock_path}" + if ( + cd "${repository}" + verify_checked_in_locked_input "${lock_path}" + ); then + fail "dirty working-tree lock unexpectedly succeeded" + fi + + git -C "${repository}" add -- "locks/fdroid lock.json" + git -C "${repository}" show 'HEAD:locks/fdroid lock.json' >"${lock_path}" + if ( + cd "${repository}" + verify_checked_in_locked_input "${lock_path}" + ); then + fail "staged-only lock change unexpectedly succeeded" + fi + + git -C "${repository}" restore --staged --worktree -- \ + "locks/fdroid lock.json" + chmod +x "${lock_path}" + if ( + cd "${repository}" + verify_checked_in_locked_input "${lock_path}" + ); then + fail "mode-only lock change unexpectedly succeeded" + fi + + chmod -x "${lock_path}" + printf '%s\n' '{"fixture":"outside"}' >"${outside_path}" + if ( + cd "${repository}" + verify_checked_in_locked_input "${outside_path}" + ); then + fail "outside-repository lock unexpectedly succeeded" + fi + + local alias_path="${repository}/locks/fdroid-alias.json" + ln -s -- "fdroid lock.json" "${alias_path}" + if ( + cd "${repository}" + verify_checked_in_locked_input "${alias_path}" + ); then + fail "symlink lock unexpectedly succeeded" + fi +} + test_matching_signature_succeeds test_missing_signature_fails test_wrong_module_signature_fails test_unsigned_exceptions_succeed +test_locked_inputs_require_explicit_checked_in_files +test_locked_input_rejects_worktree_index_mode_and_path_aliases echo "verifier tests passed" From 2e546af530f1b31a3ed866cccedd8b84b347c265 Mon Sep 17 00:00:00 2001 From: 0cwa Date: Sat, 18 Jul 2026 14:49:12 +0200 Subject: [PATCH 05/18] Verify locked modules before OTA extraction --- src/util_functions.sh | 25 +++++++-- tests/module_selection_test.sh | 98 ++++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 5 deletions(-) diff --git a/src/util_functions.sh b/src/util_functions.sh index 4d25474f..ea8ad0cf 100755 --- a/src/util_functions.sh +++ b/src/util_functions.sh @@ -281,12 +281,23 @@ function patch_ota() { local grapheneos_otacert="${WORKDIR}/extracted/ota/META-INF/com/android/otacert" local magisk_path="${WORKDIR}/modules/magisk.apk" local my_avbroot_setup="${WORKDIR}/tools/my-avbroot-setup" + local -a locked_module_args=() # Activate the virtual environment if [ -z "${VIRTUAL_ENV}" ]; then enable_venv fi + # Locked module artifacts must be resolved, fetched, and verified before any + # OTA contents are unpacked. Keep the disabled path on its legacy ordering. + if [[ "${ADDITIONALS[FDROID_PRIVILEGED_EXTENSION]}" == 'true' ]]; then + rm -rf -- "${WORKDIR}/extracted/extracts/" + if ! prepare_fdroid_privileged_extension \ + locked_module_args "${my_avbroot_setup}"; then + return 1 + fi + fi + # Extract the official public keys and certificates if not found if [[ ! -e "${grapheneos_pkmd}" || ! -e "${grapheneos_otacert}" ]]; then echo "Extracting official keys..." @@ -319,14 +330,18 @@ function patch_ota() { args+=("--pass-avb-env-var" "PASSPHRASE_AVB") args+=("--pass-ota-env-var" "PASSPHRASE_OTA") - # Clear the extraction scratch tree before locked acquisition. A - # caller-selected cache below this tree must not be verified and then - # deleted before patch.py consumes it. - rm -rf -- "${WORKDIR}/extracted/extracts/" + # Preserve the legacy cleanup ordering when locked modules are disabled. + # Enabled builds already cleared this tree before locked acquisition so a + # caller-selected cache below it remains available to patch.py. + if [[ "${ADDITIONALS[FDROID_PRIVILEGED_EXTENSION]}" != 'true' ]]; then + rm -rf -- "${WORKDIR}/extracted/extracts/" + fi # Modules and their signatures append_enabled_module_arguments args - if ! prepare_fdroid_privileged_extension args "${my_avbroot_setup}"; then + if [[ "${ADDITIONALS[FDROID_PRIVILEGED_EXTENSION]}" == 'true' ]]; then + args+=("${locked_module_args[@]}") + elif ! prepare_fdroid_privileged_extension args "${my_avbroot_setup}"; then return 1 fi diff --git a/tests/module_selection_test.sh b/tests/module_selection_test.sh index 923f8abc..b13ddd14 100644 --- a/tests/module_selection_test.sh +++ b/tests/module_selection_test.sh @@ -358,8 +358,103 @@ test_fdroid_missing_or_untracked_inputs_fail_closed() { LOCKED_INPUTS_VALID="false" assert_patch_fails_before_execution "untracked locked inputs" assert_prepare_stages "untracked locked inputs" + + reset_fixture fdroid-missing-module-tool + enable_fdroid_fixture + rm -f -- "${WORKDIR}/tools/my-avbroot-setup/module-tool.py" + assert_patch_fails_before_execution "missing locked module tool" + assert_prepare_stages "missing locked module tool" } +test_fdroid_preparation_precedes_ota_extraction() ( + local event_log + local -a expected_events=(prepare-start prepare-complete extract) + local -a actual_events=() + + reset_fixture fdroid-before-extraction + enable_fdroid_fixture + rm -f -- \ + "${WORKDIR}/extracted/avb_pkmd.bin" \ + "${WORKDIR}/extracted/ota/META-INF/com/android/otacert" + event_log="${WORKDIR}/events" + + prepare_fdroid_privileged_extension() { + printf '%s\n' prepare-start >>"${event_log}" + printf '%s\n' prepare-complete >>"${event_log}" + } + + extract_official_keys() { + [[ "$(tail -n 1 -- "${event_log}")" == 'prepare-complete' ]] || + fail "OTA extraction started before locked preparation completed" + printf '%s\n' extract >>"${event_log}" + mkdir -p -- "${WORKDIR}/extracted/ota/META-INF/com/android" + touch -- \ + "${WORKDIR}/extracted/avb_pkmd.bin" \ + "${WORKDIR}/extracted/ota/META-INF/com/android/otacert" + } + + run_patch + mapfile -t actual_events <"${event_log}" + assert_array_equals \ + expected_events actual_events "F-Droid before OTA extraction" +) + +test_fdroid_preparation_failure_prevents_ota_extraction() ( + local event_log + + reset_fixture fdroid-failure-before-extraction + enable_fdroid_fixture + rm -f -- \ + "${WORKDIR}/extracted/avb_pkmd.bin" \ + "${WORKDIR}/extracted/ota/META-INF/com/android/otacert" + event_log="${WORKDIR}/events" + + prepare_fdroid_privileged_extension() { + printf '%s\n' prepare-failed >>"${event_log}" + return 1 + } + + extract_official_keys() { + printf '%s\n' extract >>"${event_log}" + } + + assert_patch_fails_before_execution "locked preparation before extraction" + [[ "$(<"${event_log}")" == 'prepare-failed' ]] || + fail "OTA extraction ran after locked preparation failed" +) + +test_fdroid_disabled_preserves_legacy_extraction_order() ( + local event_log + local -a expected_events=(extract prepare) + local -a actual_events=() + + reset_fixture fdroid-disabled-extraction + set_default_expected_args + rm -f -- \ + "${WORKDIR}/extracted/avb_pkmd.bin" \ + "${WORKDIR}/extracted/ota/META-INF/com/android/otacert" + event_log="${WORKDIR}/events" + + extract_official_keys() { + printf '%s\n' extract >>"${event_log}" + mkdir -p -- "${WORKDIR}/extracted/ota/META-INF/com/android" + touch -- \ + "${WORKDIR}/extracted/avb_pkmd.bin" \ + "${WORKDIR}/extracted/ota/META-INF/com/android/otacert" + } + + prepare_fdroid_privileged_extension() { + printf '%s\n' prepare >>"${event_log}" + } + + run_patch + mapfile -t actual_events <"${event_log}" + assert_array_equals \ + expected_events actual_events "disabled F-Droid extraction order" + assert_array_equals \ + EXPECTED_ARGS CAPTURED_ARGS "disabled F-Droid missing-key arguments" +) + test_fdroid_preparation_stages_fail_closed() { local failure @@ -418,6 +513,9 @@ test_all_modules_can_be_disabled test_special_cases_remain_available test_fdroid_locked_preparation_and_arguments test_fdroid_missing_or_untracked_inputs_fail_closed +test_fdroid_preparation_precedes_ota_extraction +test_fdroid_preparation_failure_prevents_ota_extraction +test_fdroid_disabled_preserves_legacy_extraction_order test_fdroid_preparation_stages_fail_closed test_fdroid_locked_paths_survive_cleanup_and_quoting test_fdroid_enabled_does_not_reuse_legacy_output_marker From 6317d1afeb56c4741a851deef66710dc8af4f29d Mon Sep 17 00:00:00 2001 From: 0cwa Date: Sat, 18 Jul 2026 15:19:28 +0200 Subject: [PATCH 06/18] Add shared ROM profile resolution --- src/declarations.sh | 17 +- src/fetcher.sh | 27 +-- src/main.sh | 5 +- src/ota_providers.sh | 91 ++++++++++ src/rom_profiles.sh | 154 +++++++++++++++++ src/util_functions.sh | 18 +- tests/phase3_provider_acquisition_test.sh | 155 +++++++++++++++++ tests/phase3_rom_contract_test.sh | 194 ++++++++++++++++++++++ 8 files changed, 632 insertions(+), 29 deletions(-) create mode 100644 src/ota_providers.sh create mode 100644 src/rom_profiles.sh create mode 100755 tests/phase3_provider_acquisition_test.sh create mode 100755 tests/phase3_rom_contract_test.sh diff --git a/src/declarations.sh b/src/declarations.sh index 62692756..82e50402 100755 --- a/src/declarations.sh +++ b/src/declarations.sh @@ -9,6 +9,7 @@ declare -A GRAPHENEOS declare -A KEYS declare -A MAGISK declare -A OUTPUTS +declare -A ROM_PROFILE declare -A VERSION # Build Specifications @@ -20,6 +21,9 @@ ARCH="x86_64-unknown-linux-gnu" # for Linux CLEANUP="${CLEANUP:-'false'}" # Clean up after the script finishes DEVICE_NAME="${DEVICE_NAME:-}" # Device name, passed from the CI environment INTERACTIVE_MODE="${INTERACTIVE_MODE:-true}" # Enable interactive mode +ROM_FAMILY="${ROM_FAMILY:-grapheneos}" +OUTPUT_SCOPE="${OUTPUT_SCOPE:-local-unpublished}" +MODULE_SELECTION_FINGERPRINT="${MODULE_SELECTION_FINGERPRINT:-}" WORKDIR=".tmp" # GitHub variables @@ -57,12 +61,13 @@ KEYS[OTA]="${KEYS[OTA]:-ota.key}" KEYS[OTA_BASE64]="${KEYS[OTA_BASE64]:-''}" KEYS[PKMD]="${KEYS[PKMD]:-avb_pkmd.bin}" -# GrapheneOS -GRAPHENEOS[OTA_BASE_URL]="https://releases.grapheneos.org" -GRAPHENEOS[UPDATE_CHANNEL]="${GRAPHENEOS_UPDATE_CHANNEL:-stable}" -GRAPHENEOS[UPDATE_TYPE]="${GRAPHENEOS[UPDATE_TYPE]:-ota_update}" # avbroot supports only `ota_update` and not `install` (factory images) -GRAPHENEOS[OTA_URL]="${GRAPHENEOS[OTA_URL]:-}" # Will be constructed from the latest version -GRAPHENEOS[OTA_TARGET]="${GRAPHENEOS[OTA_TARGET]:-}" # Will be constructed from the latest version +# Compatibility keys retained for existing callers. resolve_rom_profile fills +# these through the common ROM capability profile. +GRAPHENEOS[OTA_BASE_URL]="${GRAPHENEOS[OTA_BASE_URL]:-}" +GRAPHENEOS[UPDATE_CHANNEL]="${GRAPHENEOS[UPDATE_CHANNEL]:-}" +GRAPHENEOS[UPDATE_TYPE]="${GRAPHENEOS[UPDATE_TYPE]:-}" +GRAPHENEOS[OTA_URL]="${GRAPHENEOS[OTA_URL]:-}" +GRAPHENEOS[OTA_TARGET]="${GRAPHENEOS[OTA_TARGET]:-}" # Additionals diff --git a/src/fetcher.sh b/src/fetcher.sh index 92b9c17e..3caeb27a 100755 --- a/src/fetcher.sh +++ b/src/fetcher.sh @@ -5,10 +5,11 @@ # Contains the functions to fetch the required files. In short, this takes care of downloading the OTA, Magisk, and other dependencies. source src/declarations.sh +source src/rom_profiles.sh +source src/ota_providers.sh # Fetch the latest version of GrapheneOS and Magisk and sets up the OTA URL function get_latest_version() { - local latest_grapheneos_version=$(curl -sL "${GRAPHENEOS[OTA_BASE_URL]}/${DEVICE_NAME}-${GRAPHENEOS[UPDATE_CHANNEL]}" | sed 's/ .*//') local latest_magisk_version=$( git ls-remote --tags "${DOMAIN}/${MAGISK[REPOSITORY]}.git" | awk -F'\t' '{print $2}' | @@ -18,27 +19,15 @@ function get_latest_version() { tail -n1 ) - if [[ GRAPHENEOS[UPDATE_TYPE] == "install" ]]; then - echo -e "The update type is set to \`install\` which is not supported by AVBRoot.\nExiting..." - exit 1 - fi - - # Construct the URLs - GRAPHENEOS[OTA_TARGET]="${DEVICE_NAME}-${GRAPHENEOS[UPDATE_TYPE]}-${latest_grapheneos_version}" - # e.g. https://releases.grapheneos.org/bluejay-stable - GRAPHENEOS[OTA_URL]="${GRAPHENEOS[OTA_BASE_URL]}/${GRAPHENEOS[OTA_TARGET]}.zip" + resolve_rom_profile || return 1 - # e.g. bluejay-ota_update-2024080200 - echo -e "GrapheneOS OTA target: \`${GRAPHENEOS[OTA_TARGET]}\`\nGrapheneOS OTA URL: ${GRAPHENEOS[OTA_URL]}\n" - - if [[ -z "${latest_grapheneos_version}" ]]; then - echo -e "Failed to get the latest version." + if [[ "${GRAPHENEOS[UPDATE_TYPE]}" == "install" ]]; then + echo -e "The update type is set to \`install\` which is not supported by AVBRoot.\nExiting..." exit 1 fi - if [[ -z "${VERSION[GRAPHENEOS]}" ]]; then - VERSION[GRAPHENEOS]="${GRAPHENEOS_VERSION:-${latest_grapheneos_version}}" - fi + fetch_rom_ota_metadata || return 1 + echo -e "${ROM_FAMILY} OTA target: \`${GRAPHENEOS[OTA_TARGET]}\`\nOTA URL: ${GRAPHENEOS[OTA_URL]}\n" if [[ -z "${latest_magisk_version}" ]]; then echo -e "Failed to get the latest Magisk version." @@ -95,7 +84,7 @@ function download_ota() { local ota="${WORKDIR}/${GRAPHENEOS[OTA_TARGET]}.zip" # Set the URLs if not set - if [ -n "${GRAPHENEOS[OTA_URL]}" ]; then + if [[ -z "${GRAPHENEOS[OTA_URL]}" || -z "${GRAPHENEOS[OTA_TARGET]}" ]]; then get_latest_version fi diff --git a/src/main.sh b/src/main.sh index 7bafd2a5..2af82533 100755 --- a/src/main.sh +++ b/src/main.sh @@ -17,7 +17,10 @@ function main() { check_toml_env fi - # Fetch the latest version of GrapheneOS and Magisk + resolve_rom_profile + enforce_output_policy "${OUTPUT_SCOPE}" + + # Fetch the latest ROM version and Magisk get_latest_version # Check for requirements and download them accordingly check_and_download_dependencies diff --git a/src/ota_providers.sh b/src/ota_providers.sh new file mode 100644 index 00000000..9ea26933 --- /dev/null +++ b/src/ota_providers.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 PixeneOS contributors + +function fetch_grapheneos_ota_metadata() { + local release_metadata latest_version + + release_metadata="$(curl -sLf \ + "${ROM_PROFILE[OTA_BASE_URL]}/${DEVICE_NAME}-${GRAPHENEOS[UPDATE_CHANNEL]}")" || { + echo "Error: failed to fetch GrapheneOS release metadata." >&2 + return 1 + } + latest_version="${release_metadata%%[[:space:]]*}" + if [[ ! "${latest_version}" =~ ^[0-9]{8,14}$ ]]; then + echo "Error: GrapheneOS returned an invalid release version." >&2 + return 1 + fi + + VERSION[GRAPHENEOS]="${GRAPHENEOS_VERSION:-${latest_version}}" + GRAPHENEOS[OTA_TARGET]="${DEVICE_NAME}-${GRAPHENEOS[UPDATE_TYPE]}-${latest_version}" + GRAPHENEOS[OTA_URL]="${ROM_PROFILE[OTA_BASE_URL]}/${GRAPHENEOS[OTA_TARGET]}.zip" +} + +function fetch_lineageos_ota_metadata() { + local release_metadata parsed filename ota_url latest_version + local endpoint="${ROM_PROFILE[OTA_BASE_URL]}/devices/${DEVICE_NAME}/builds" + + release_metadata="$(curl -sLf "${endpoint}")" || { + echo "Error: failed to fetch LineageOS release metadata." >&2 + return 1 + } + parsed="$(printf '%s' "${release_metadata}" | python3 -c ' +import json +import re +import sys +from urllib.parse import urlsplit + +try: + builds = json.load(sys.stdin) + channel = sys.argv[1] + device = sys.argv[2] + build = next(item for item in builds if item["type"] == channel) + item = next( + item for item in build["files"] + if item["filename"].endswith(".zip") and item["type"] == channel + ) + filename = item["filename"] + url = item["url"] +except (KeyError, IndexError, StopIteration, TypeError, ValueError, json.JSONDecodeError): + raise SystemExit(1) +if not isinstance(filename, str) or not re.fullmatch(r"[A-Za-z0-9._+-]+[.]zip", filename): + raise SystemExit(1) +if not re.fullmatch(r"[a-z0-9_]+", device) or f"-{device}-" not in filename: + raise SystemExit(1) +parts = urlsplit(url) +if ( + parts.scheme != "https" + or parts.hostname not in {"download.lineageos.org", "mirrorbits.lineageos.org"} + or parts.username + or parts.password +): + raise SystemExit(1) +print(filename) +print(url) +' "${GRAPHENEOS[UPDATE_CHANNEL]}" "${DEVICE_NAME}")" || { + echo "Error: LineageOS returned invalid release metadata." >&2 + return 1 + } + filename="${parsed%%$'\n'*}" + ota_url="${parsed#*$'\n'}" + latest_version="$(printf '%s\n' "${filename}" | sed -nE 's/.*(^|[^0-9])([0-9]{8})([^0-9]|$).*/\2/p')" + if [[ ! "${latest_version}" =~ ^[0-9]{8}$ ]]; then + echo "Error: LineageOS filename lacks an unambiguous build date." >&2 + return 1 + fi + + VERSION[GRAPHENEOS]="${GRAPHENEOS_VERSION:-${latest_version}}" + GRAPHENEOS[OTA_TARGET]="${filename%.zip}" + GRAPHENEOS[OTA_URL]="${ota_url}" +} + +function fetch_rom_ota_metadata() { + case "${ROM_PROFILE[PROVIDER]}" in + grapheneos) fetch_grapheneos_ota_metadata ;; + lineageos) fetch_lineageos_ota_metadata ;; + *) + echo "Error: unsupported OTA metadata provider." >&2 + return 1 + ;; + esac +} diff --git a/src/rom_profiles.sh b/src/rom_profiles.sh new file mode 100644 index 00000000..84cc8e2c --- /dev/null +++ b/src/rom_profiles.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 PixeneOS contributors + +# ROM differences are data consumed by the common build path. Provider-specific +# metadata parsing lives in ota_providers.sh. +declare -gA ROM_PROFILE + +function _require_profile_boolean() { + local name="${1}" + local value="${2}" + + if [[ "${value}" != 'true' && "${value}" != 'false' ]]; then + echo "Error: ${name} must be true or false." >&2 + return 1 + fi +} + +function resolve_rom_profile() { + case "${ROM_FAMILY}" in + grapheneos) + ROM_PROFILE[PROVIDER]="grapheneos" + ROM_PROFILE[DEFAULT_UPDATE_CHANNEL]="stable" + ROM_PROFILE[DEFAULT_UPDATE_TYPE]="ota_update" + ROM_PROFILE[DEFAULT_COMPATIBLE_SEPOLICY]="false" + ROM_PROFILE[CLEAR_VBMETA_FLAGS]="false" + ROM_PROFILE[OTA_BASE_URL]="https://releases.grapheneos.org" + ;; + lineageos) + ROM_PROFILE[PROVIDER]="lineageos" + ROM_PROFILE[DEFAULT_UPDATE_CHANNEL]="nightly" + ROM_PROFILE[DEFAULT_UPDATE_TYPE]="ota_update" + ROM_PROFILE[DEFAULT_COMPATIBLE_SEPOLICY]="true" + ROM_PROFILE[CLEAR_VBMETA_FLAGS]="true" + ROM_PROFILE[OTA_BASE_URL]="https://download.lineageos.org/api/v2" + ;; + *) + echo "Error: unsupported ROM_FAMILY: ${ROM_FAMILY}" >&2 + return 1 + ;; + esac + + GRAPHENEOS[OTA_BASE_URL]="${ROM_PROFILE[OTA_BASE_URL]}" + GRAPHENEOS[UPDATE_CHANNEL]="${GRAPHENEOS_UPDATE_CHANNEL:-${ROM_PROFILE[DEFAULT_UPDATE_CHANNEL]}}" + GRAPHENEOS[UPDATE_TYPE]="${ROM_UPDATE_TYPE:-${ROM_PROFILE[DEFAULT_UPDATE_TYPE]}}" + GRAPHENEOS[OTA_URL]="${GRAPHENEOS[OTA_URL]:-}" + GRAPHENEOS[OTA_TARGET]="${GRAPHENEOS[OTA_TARGET]:-}" + + if [[ -n "${ADDITIONALS_MAS_COMPATIBLE_SEPOLICY:-}" ]]; then + ADDITIONALS[MAS_COMPATIBLE_SEPOLICY]="${ADDITIONALS_MAS_COMPATIBLE_SEPOLICY}" + else + ADDITIONALS[MAS_COMPATIBLE_SEPOLICY]="${ROM_PROFILE[DEFAULT_COMPATIBLE_SEPOLICY]}" + fi + + _require_profile_boolean \ + ADDITIONALS_MAS_COMPATIBLE_SEPOLICY \ + "${ADDITIONALS[MAS_COMPATIBLE_SEPOLICY]}" + _require_profile_boolean \ + ROM_PROFILE_CLEAR_VBMETA_FLAGS \ + "${ROM_PROFILE[CLEAR_VBMETA_FLAGS]}" +} + +function enforce_output_policy() { + local output_scope="${1}" + + case "${output_scope}" in + local-unpublished | private | shared | published) ;; + *) + echo "Error: unknown output scope: ${output_scope}" >&2 + return 1 + ;; + esac + + if [[ "${ADDITIONALS[DEBUG]}" == 'true' && + "${output_scope}" != 'local-unpublished' ]]; then + echo "Error: debug ADB output must remain local and unpublished." >&2 + return 1 + fi + + if [[ "${ADDITIONALS[FDROID_PRIVILEGED_EXTENSION]}" == 'true' && + "${output_scope}" != 'local-unpublished' ]]; then + echo "Error: F-Droid output is restricted to local-unpublished." >&2 + return 1 + fi +} + +function _locked_input_digest() { + local input_path="${1}" + + if ! declare -F verify_checked_in_locked_input >/dev/null; then + source src/verifier.sh + fi + verify_checked_in_locked_input "${input_path}" || return 1 + sha256sum -- "${input_path}" | awk '{print $1}' +} + +function module_selection_fingerprint() { + local lock_digest="disabled" + local profile_digest="disabled" + local entry + local -a module_entries=( + "alterinstaller:ALTERINSTALLER" + "bcr:BCR" + "custota:CUSTOTA" + "fdroid-privileged-extension:FDROID_PRIVILEGED_EXTENSION" + "msd:MSD" + "oemunlockonboot:OEMUNLOCKONBOOT" + ) + + resolve_rom_profile || return 1 + enforce_output_policy "${OUTPUT_SCOPE}" || return 1 + + _require_profile_boolean ADDITIONALS_ROOT "${ADDITIONALS[ROOT]}" || return 1 + _require_profile_boolean ADDITIONALS_DEBUG "${ADDITIONALS[DEBUG]}" || return 1 + for entry in "${module_entries[@]}"; do + _require_profile_boolean \ + "ADDITIONALS_${entry#*:}" \ + "${ADDITIONALS[${entry#*:}]}" || return 1 + done + + if [[ "${ADDITIONALS[FDROID_PRIVILEGED_EXTENSION]}" == 'true' ]]; then + lock_digest="$(_locked_input_digest "${FDROID_PRIVILEGED_EXTENSION_LOCK}")" || { + echo "Error: the F-Droid lock is not clean and checked in." >&2 + return 1 + } + profile_digest="$(_locked_input_digest "${FDROID_PRIVILEGED_EXTENSION_PROFILE}")" || { + echo "Error: the F-Droid profile is not clean and checked in." >&2 + return 1 + } + fi + + MODULE_SELECTION_FINGERPRINT="$({ + printf '%s\n' \ + 'pixene-module-selection-v1' \ + "rom_family=${ROM_FAMILY}" \ + "output_scope=${OUTPUT_SCOPE}" \ + "root=${ADDITIONALS[ROOT]}" \ + "debug=${ADDITIONALS[DEBUG]}" \ + "compatible_sepolicy=${ADDITIONALS[MAS_COMPATIBLE_SEPOLICY]}" \ + "clear_vbmeta_flags=${ROM_PROFILE[CLEAR_VBMETA_FLAGS]}" \ + "helper_commit=${VERSION[AVBROOT_SETUP]}" \ + "lock_sha256=${lock_digest}" \ + "profile_sha256=${profile_digest}" + for entry in "${module_entries[@]}"; do + printf 'module.%s=%s\n' "${entry%%:*}" "${ADDITIONALS[${entry#*:}]}" + done + } | sha256sum | awk '{print $1}')" + + if [[ ! "${MODULE_SELECTION_FINGERPRINT}" =~ ^[0-9a-f]{64}$ ]]; then + echo "Error: failed to compute the module-selection fingerprint." >&2 + return 1 + fi + printf '%s\n' "${MODULE_SELECTION_FINGERPRINT}" +} diff --git a/src/util_functions.sh b/src/util_functions.sh index ea8ad0cf..85c6833d 100755 --- a/src/util_functions.sh +++ b/src/util_functions.sh @@ -9,6 +9,7 @@ source src/exchange.sh source src/fetcher.sh source src/verifier.sh source src/debug_module_setup.sh +source src/rom_profiles.sh # Function to check and download the dependencies # This function checks for the required tools and downloads them if not found depending on the configuration done in the declarations file @@ -270,6 +271,10 @@ function generate_keys() { # Leverages `my-avbroot-setup` to patch the OTA # This function does a lot of things before patching the OTA function patch_ota() { + if [[ -z "${ROM_PROFILE[PROVIDER]:-}" ]]; then + resolve_rom_profile + fi + if [[ "${INTERACTIVE_MODE}" != 'true' ]]; then base64_decode fi @@ -345,6 +350,10 @@ function patch_ota() { return 1 fi + if [[ "${ROM_PROFILE[CLEAR_VBMETA_FLAGS]}" == 'true' ]]; then + args+=("--patch-arg=--clear-vbmeta-flags") + fi + # Add debug module if unauthorized ADB is enabled if [[ "${ADDITIONALS[DEBUG]}" == 'true' ]]; then echo -e "Unauthorized ADB is enabled. Setting up debug module...\n" @@ -643,9 +652,12 @@ function generate_ota_info() { debug_suffix="-debug-adb" fi - # e.g. bluejay-2024082200-rootless-abc12345-dirty.zip - # Debug builds are intentionally labeled, e.g. bluejay-2024082200-rootless-debug-adb-abc12345.zip - OUTPUTS[PATCHED_OTA]="${DEVICE_NAME}-${VERSION[GRAPHENEOS]}-${flavor}${debug_suffix}-$(git rev-parse --short HEAD)$(dirty_suffix).zip" + module_selection_fingerprint >/dev/null + local fingerprint_short="${MODULE_SELECTION_FINGERPRINT:0:16}" + + # Debug builds are intentionally labeled. The stable selection fingerprint + # prevents otherwise identical ROM/profile variants from colliding. + OUTPUTS[PATCHED_OTA]="${DEVICE_NAME}-${VERSION[GRAPHENEOS]}-${flavor}${debug_suffix}-${fingerprint_short}-$(git rev-parse --short HEAD)$(dirty_suffix).zip" } function check_toml_env() { diff --git a/tests/phase3_provider_acquisition_test.sh b/tests/phase3_provider_acquisition_test.sh new file mode 100755 index 00000000..3031e909 --- /dev/null +++ b/tests/phase3_provider_acquisition_test.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2024-2026 PixeneOS contributors + +set -euo pipefail + +[[ -f src/fetcher.sh ]] || { + echo "missing OTA fetcher: src/fetcher.sh" >&2 + exit 1 +} +source src/fetcher.sh + +fail() { + echo "$*" >&2 + exit 1 +} + +assert_equals() { + local expected="${1}" + local actual="${2}" + local context="${3}" + + [[ "${actual}" == "${expected}" ]] || + fail "${context}: expected ${expected}, got ${actual}" +} + +mock_magisk_tags() { + if [[ "${1:-}" == "ls-remote" ]]; then + printf '1111111111111111111111111111111111111111\trefs/tags/v28.1\n' + printf '2222222222222222222222222222222222222222\trefs/tags/v29.0\n' + return 0 + fi + fail "unexpected git invocation: $*" +} + +reset_acquisition_fixture() { + unset GRAPHENEOS_VERSION GRAPHENEOS_UPDATE_CHANNEL ROM_UPDATE_TYPE + VERSION[GRAPHENEOS]="" + VERSION[MAGISK]="" + GRAPHENEOS[OTA_URL]="" + GRAPHENEOS[OTA_TARGET]="" + ADDITIONALS_MAS_COMPATIBLE_SEPOLICY="" +} + +test_grapheneos_text_metadata() ( + reset_acquisition_fixture + ROM_FAMILY="grapheneos" + DEVICE_NAME="shiba" + + git() { mock_magisk_tags "$@"; } + curl() { + local url="${!#}" + [[ "${url}" == "https://releases.grapheneos.org/shiba-stable" ]] || + fail "unexpected GrapheneOS metadata URL: ${url}" + printf '%s\n' '2026071700 1784260800' + } + + get_latest_version >/dev/null + + assert_equals "2026071700" "${VERSION[GRAPHENEOS]}" \ + "GrapheneOS version" + assert_equals "shiba-ota_update-2026071700" \ + "${GRAPHENEOS[OTA_TARGET]}" "GrapheneOS OTA target" + assert_equals \ + "https://releases.grapheneos.org/shiba-ota_update-2026071700.zip" \ + "${GRAPHENEOS[OTA_URL]}" "GrapheneOS OTA URL" + assert_equals "v29.0" "${VERSION[MAGISK]}" "Magisk version" +) + +test_lineageos_v2_metadata() ( + reset_acquisition_fixture + ROM_FAMILY="lineageos" + DEVICE_NAME="pdx235" + + git() { mock_magisk_tags "$@"; } + curl() { + local url="${!#}" + [[ "${url}" == \ + "https://download.lineageos.org/api/v2/devices/pdx235/builds" ]] || + fail "unexpected LineageOS metadata URL: ${url}" + printf '%s\n' '[ + { + "date": "2026-07-17", + "datetime": 1784271720, + "files": [ + { + "filename": "lineage-23.2-20260717-nightly-pdx235-signed.zip", + "sha256": "df27d06052a79f0acc24e8862b70a0c32f188e4a6f107964c93bdb54ade7accc", + "type": "nightly", + "url": "https://mirrorbits.lineageos.org/full/pdx235/20260717/lineage-23.2-20260717-nightly-pdx235-signed.zip" + }, + { + "filename": "boot.img", + "url": "https://mirrorbits.lineageos.org/full/pdx235/20260717/boot.img" + } + ], + "type": "nightly", + "version": "23.2" + } + ]' + } + + get_latest_version >/dev/null + + assert_equals "20260717" "${VERSION[GRAPHENEOS]}" "LineageOS version" + assert_equals "lineage-23.2-20260717-nightly-pdx235-signed" \ + "${GRAPHENEOS[OTA_TARGET]}" "LineageOS OTA target" + assert_equals \ + "https://mirrorbits.lineageos.org/full/pdx235/20260717/lineage-23.2-20260717-nightly-pdx235-signed.zip" \ + "${GRAPHENEOS[OTA_URL]}" "LineageOS OTA URL" + assert_equals "v29.0" "${VERSION[MAGISK]}" "Magisk version" +) + +test_invalid_grapheneos_metadata_fails_closed() ( + reset_acquisition_fixture + ROM_FAMILY="grapheneos" + DEVICE_NAME="shiba" + + git() { mock_magisk_tags "$@"; } + curl() { printf '%s\n' 'not release metadata'; } + + if get_latest_version >/dev/null 2>&1; then + fail "invalid GrapheneOS metadata unexpectedly succeeded" + fi +) + +test_unsafe_lineageos_metadata_fails_closed() ( + reset_acquisition_fixture + ROM_FAMILY="lineageos" + DEVICE_NAME="pdx235" + + git() { mock_magisk_tags "$@"; } + curl() { + printf '%s\n' '[{ + "date": "2026-07-17", + "files": [{ + "filename": "lineage-23.2-20260717-nightly-pdx235-signed.zip", + "url": "http://mirror.example/lineage-23.2-20260717-nightly-pdx235-signed.zip" + }], + "type": "nightly", + "version": "23.2" + }]' + } + + if get_latest_version >/dev/null 2>&1; then + fail "LineageOS metadata with a non-HTTPS OTA URL unexpectedly succeeded" + fi +) + +test_grapheneos_text_metadata +test_lineageos_v2_metadata +test_invalid_grapheneos_metadata_fails_closed +test_unsafe_lineageos_metadata_fails_closed + +echo "Phase 3 provider acquisition tests passed" diff --git a/tests/phase3_rom_contract_test.sh b/tests/phase3_rom_contract_test.sh new file mode 100755 index 00000000..7c6f99fc --- /dev/null +++ b/tests/phase3_rom_contract_test.sh @@ -0,0 +1,194 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2024-2026 PixeneOS contributors + +set -euo pipefail + +TEST_ROOT="$(mktemp -d)" +trap 'rm -rf -- "${TEST_ROOT}"' EXIT + +[[ -f src/rom_profiles.sh ]] || { + echo "missing ROM profile implementation: src/rom_profiles.sh" >&2 + exit 1 +} +source src/rom_profiles.sh +source src/util_functions.sh + +fail() { + echo "$*" >&2 + exit 1 +} + +assert_equals() { + local expected="${1}" + local actual="${2}" + local context="${3}" + + [[ "${actual}" == "${expected}" ]] || + fail "${context}: expected ${expected}, got ${actual}" +} + +load_contract() { + declare -F resolve_rom_profile >/dev/null || + fail "resolve_rom_profile is not defined" + declare -F module_selection_fingerprint >/dev/null || + fail "module_selection_fingerprint is not defined" + declare -F enforce_output_policy >/dev/null || + fail "enforce_output_policy is not defined" + declare -p ROM_PROFILE >/dev/null 2>&1 || + fail "ROM_PROFILE is not declared" +} + +assert_profile() ( + local family="${1}" + local provider="${2}" + local channel="${3}" + local update_type="${4}" + local compatible_sepolicy="${5}" + local base_url="${6}" + + load_contract + ROM_FAMILY="${family}" + resolve_rom_profile + + assert_equals "${provider}" "${ROM_PROFILE[PROVIDER]}" \ + "${family} provider" + assert_equals "${channel}" "${ROM_PROFILE[DEFAULT_UPDATE_CHANNEL]}" \ + "${family} default update channel" + assert_equals "${update_type}" "${ROM_PROFILE[DEFAULT_UPDATE_TYPE]}" \ + "${family} default update type" + assert_equals "${compatible_sepolicy}" \ + "${ROM_PROFILE[DEFAULT_COMPATIBLE_SEPOLICY]}" \ + "${family} compatible-SEPolicy default" + assert_equals "${base_url}" "${ROM_PROFILE[OTA_BASE_URL]}" \ + "${family} OTA base URL" +) + +test_profiles_are_stable() { + assert_profile \ + grapheneos grapheneos stable ota_update false \ + https://releases.grapheneos.org + assert_profile \ + lineageos lineageos nightly ota_update true \ + https://download.lineageos.org/api/v2 +} + +test_unknown_rom_fails_closed() ( + load_contract + ROM_FAMILY="unknown-rom" + + if resolve_rom_profile >/dev/null 2>&1; then + fail "unknown ROM family unexpectedly resolved" + fi +) + +set_selection_fixture() { + ROM_FAMILY="grapheneos" + ADDITIONALS[ROOT]="false" + ADDITIONALS[CUSTOTA]="true" + ADDITIONALS[MSD]="true" + ADDITIONALS[BCR]="true" + ADDITIONALS[OEMUNLOCKONBOOT]="true" + ADDITIONALS[ALTERINSTALLER]="true" + ADDITIONALS[FDROID_PRIVILEGED_EXTENSION]="false" + resolve_rom_profile +} + +fingerprint() { + local output_file value + + output_file="${TEST_ROOT}/fingerprint-${BASHPID}" + module_selection_fingerprint >"${output_file}" + value="$(<"${output_file}")" + [[ "${value}" =~ ^[0-9a-f]{64}$ ]] || + fail "module selection fingerprint is not a full lowercase SHA-256: ${value}" + assert_equals "${value}" "${MODULE_SELECTION_FINGERPRINT}" \ + "fingerprint function side effect" + printf '%s\n' "${value}" +} + +test_selection_fingerprint() ( + local baseline repeated root_changed rom_changed module_changed + + load_contract + set_selection_fixture + baseline="$(fingerprint)" + repeated="$(fingerprint)" + assert_equals "${baseline}" "${repeated}" "deterministic fingerprint" + + ADDITIONALS[ROOT]="true" + root_changed="$(fingerprint)" + [[ "${root_changed}" != "${baseline}" ]] || + fail "root selection did not change the fingerprint" + + ADDITIONALS[ROOT]="false" + ROM_FAMILY="lineageos" + resolve_rom_profile + rom_changed="$(fingerprint)" + [[ "${rom_changed}" != "${baseline}" ]] || + fail "ROM family did not change the fingerprint" + + ROM_FAMILY="grapheneos" + resolve_rom_profile + ADDITIONALS[BCR]="false" + module_changed="$(fingerprint)" + [[ "${module_changed}" != "${baseline}" ]] || + fail "module selection did not change the fingerprint" +) + +test_output_filename_contains_fingerprint() ( + local full compact + + load_contract + set_selection_fixture + DEVICE_NAME="shiba" + VERSION[GRAPHENEOS]="2026071700" + full="$(fingerprint)" + compact="${full:0:16}" + + git() { + if [[ "${1:-}" == "rev-parse" ]]; then + printf '%s\n' deadbee + else + command git "$@" + fi + } + dirty_suffix() { :; } + + generate_ota_info + [[ "${OUTPUTS[PATCHED_OTA]}" == *"-${compact}-"* ]] || + fail "output filename does not include selection fingerprint: ${OUTPUTS[PATCHED_OTA]}" +) + +test_output_policy() ( + local scope + + load_contract + set_selection_fixture + + for scope in local-unpublished private shared published; do + enforce_output_policy "${scope}" >/dev/null || + fail "disabled legacy profile rejected allowed scope ${scope}" + done + + ADDITIONALS[FDROID_PRIVILEGED_EXTENSION]="true" + enforce_output_policy local-unpublished >/dev/null || + fail "F-Droid profile rejected local-unpublished output" + for scope in private shared published; do + if enforce_output_policy "${scope}" >/dev/null 2>&1; then + fail "F-Droid profile unexpectedly allowed ${scope} output" + fi + done + + if enforce_output_policy unknown-scope >/dev/null 2>&1; then + fail "unknown output scope unexpectedly passed policy enforcement" + fi +) + +test_profiles_are_stable +test_unknown_rom_fails_closed +test_selection_fingerprint +test_output_filename_contains_fingerprint +test_output_policy + +echo "Phase 3 ROM contract tests passed" From 84fcfcf44a16eadaf56103d25f8b8b46b6b9011b Mon Sep 17 00:00:00 2001 From: 0cwa Date: Sat, 18 Jul 2026 15:22:06 +0200 Subject: [PATCH 07/18] Add manual shared ROM build gate --- .github/workflows/build-rom.yml | 233 ++++++++++++++++++++++++ .github/workflows/phase3-build-only.yml | 53 ++++++ 2 files changed, 286 insertions(+) create mode 100644 .github/workflows/build-rom.yml create mode 100644 .github/workflows/phase3-build-only.yml diff --git a/.github/workflows/build-rom.yml b/.github/workflows/build-rom.yml new file mode 100644 index 00000000..33d96ca2 --- /dev/null +++ b/.github/workflows/build-rom.yml @@ -0,0 +1,233 @@ +name: Reusable ROM build + +on: + workflow_call: + inputs: + rom-family: + required: true + type: string + device-id: + required: true + type: string + root: + required: true + type: boolean + magisk-preinit-device: + required: false + type: string + default: "" + update-channel: + required: true + type: string + compatible-sepolicy-patching: + required: true + type: boolean + allow-unauthorized-adb: + required: true + type: boolean + release-type: + required: true + type: string + publish: + required: true + type: boolean + secrets: + AVB_KEY: + required: true + CERT_OTA: + required: true + OTA_KEY: + required: true + PASSPHRASE_AVB: + required: true + PASSPHRASE_OTA: + required: true + GH_TOKEN: + required: false + EMAIL: + required: false + +env: + CARGO_INCREMENTAL: 1 + DEVICE_NAME: ${{ inputs.device-id }} + INTERACTIVE_MODE: false + ROM_FAMILY: ${{ inputs.rom-family }} + GRAPHENEOS_UPDATE_CHANNEL: ${{ inputs.update-channel }} + OUTPUT_SCOPE: ${{ inputs.publish && inputs.release-type != 'build-only' && 'published' || 'local-unpublished' }} + RUST_BACKTRACE: short + RUSTUP_MAX_RETRIES: 10 + GH_TOKEN: ${{ secrets.GH_TOKEN }} + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Validate build request + shell: bash + env: + ADDITIONALS_DEBUG: ${{ inputs.allow-unauthorized-adb }} + ADDITIONALS_MAS_COMPATIBLE_SEPOLICY: ${{ inputs.compatible-sepolicy-patching }} + ADDITIONALS_ROOT: ${{ inputs.root }} + run: | + if [[ "${ADDITIONALS_ROOT}" == "true" && -z "${{ inputs.magisk-preinit-device }}" ]]; then + echo "::error::magisk-preinit-device is required for rooted builds" + exit 1 + fi + case "${{ inputs.release-type }}" in + default|build-only|force-publish) ;; + *) echo "::error::Unknown release type"; exit 1 ;; + esac + + - name: Checkout shared implementation + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Enforce profile output policy + shell: bash + env: + ADDITIONALS_DEBUG: ${{ inputs.allow-unauthorized-adb }} + ADDITIONALS_MAS_COMPATIBLE_SEPOLICY: ${{ inputs.compatible-sepolicy-patching }} + ADDITIONALS_ROOT: ${{ inputs.root }} + run: | + source src/declarations.sh + source src/rom_profiles.sh + resolve_rom_profile + enforce_output_policy "${OUTPUT_SCOPE}" + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@master + with: + toolchain: stable 2 weeks ago + + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@v2.9.1 + + - name: Install Python + uses: actions/setup-python@v6 + with: + python-version: "3.12-dev" + + - name: Setup signing environment + shell: bash + run: | + echo "KEYS_AVB_BASE64<> "${GITHUB_ENV}" + echo "${{ secrets.AVB_KEY }}" >> "${GITHUB_ENV}" + echo "EOF" >> "${GITHUB_ENV}" + echo "KEYS_CERT_OTA_BASE64<> "${GITHUB_ENV}" + echo "${{ secrets.CERT_OTA }}" >> "${GITHUB_ENV}" + echo "EOF" >> "${GITHUB_ENV}" + echo "KEYS_OTA_BASE64<> "${GITHUB_ENV}" + echo "${{ secrets.OTA_KEY }}" >> "${GITHUB_ENV}" + echo "EOF" >> "${GITHUB_ENV}" + + - name: Patch OTA + shell: bash + env: + ADDITIONALS_DEBUG: ${{ inputs.allow-unauthorized-adb }} + ADDITIONALS_MAS_COMPATIBLE_SEPOLICY: ${{ inputs.compatible-sepolicy-patching }} + ADDITIONALS_ROOT: ${{ inputs.root }} + CLEANUP: true + MAGISK_PREINIT: ${{ inputs.magisk-preinit-device }} + PASSPHRASE_AVB: ${{ secrets.PASSPHRASE_AVB }} + PASSPHRASE_OTA: ${{ secrets.PASSPHRASE_OTA }} + run: | + source src/main.sh + { + echo "GRAPHENEOS_OTA_TARGET=${GRAPHENEOS[OTA_TARGET]}" + echo "GRAPHENEOS_VERSION=${VERSION[GRAPHENEOS]}" + echo "MODULE_SELECTION_FINGERPRINT=${MODULE_SELECTION_FINGERPRINT}" + echo "OUTPUTS_PATCHED_OTA=${OUTPUTS[PATCHED_OTA]}" + echo "WORKDIR=${WORKDIR}" + } >> "${GITHUB_ENV}" + + - name: Record build metadata + shell: bash + run: | + { + echo "ROM family: ${ROM_FAMILY}" + echo "Device: ${DEVICE_NAME}" + echo "Selection fingerprint: ${MODULE_SELECTION_FINGERPRINT}" + echo "Output scope: ${OUTPUT_SCOPE}" + } >> "${GITHUB_STEP_SUMMARY}" + + - name: Upload build-only artifact + if: inputs.publish == false || inputs.release-type == 'build-only' + uses: actions/upload-artifact@v4 + with: + name: ${{ inputs.rom-family }}-${{ inputs.device-id }}-${{ env.MODULE_SELECTION_FINGERPRINT }} + if-no-files-found: error + path: | + ${{ env.OUTPUTS_PATCHED_OTA }} + ${{ env.OUTPUTS_PATCHED_OTA }}.csig + + - name: Re-enforce publication policy + if: inputs.publish && inputs.release-type != 'build-only' + shell: bash + env: + ADDITIONALS_DEBUG: ${{ inputs.allow-unauthorized-adb }} + ADDITIONALS_MAS_COMPATIBLE_SEPOLICY: ${{ inputs.compatible-sepolicy-patching }} + ADDITIONALS_ROOT: ${{ inputs.root }} + run: | + source src/declarations.sh + source src/rom_profiles.sh + resolve_rom_profile + enforce_output_policy "${OUTPUT_SCOPE}" + [[ "${MODULE_SELECTION_FINGERPRINT}" =~ ^[0-9a-f]{64}$ ]] + + - name: Generate changelog + if: inputs.publish && inputs.release-type != 'build-only' + shell: bash + run: | + { + echo "ROM family: ${ROM_FAMILY}" + echo "Device: ${DEVICE_NAME}" + echo "Module-selection fingerprint: ${MODULE_SELECTION_FINGERPRINT}" + } > "${GITHUB_WORKSPACE}-CHANGELOG.txt" + + - name: Publish GitHub release + if: inputs.publish && inputs.release-type != 'build-only' + uses: softprops/action-gh-release@v3 + with: + body_path: ${{ github.workspace }}-CHANGELOG.txt + files: | + ${{ env.OUTPUTS_PATCHED_OTA }} + ${{ env.OUTPUTS_PATCHED_OTA }}.csig + name: ${{ env.GRAPHENEOS_VERSION }} + tag_name: ${{ env.GRAPHENEOS_VERSION }} + + - name: Publish OTA metadata + if: inputs.publish && inputs.release-type != 'build-only' + shell: bash + run: | + git config user.email "${{ secrets.EMAIL }}" + git config user.name "${{ github.repository_owner }}" + current_commit="$(git rev-parse --short HEAD)" + if [[ "${{ inputs.root }}" == 'true' ]]; then + flavor='magisk' + else + flavor='rootless' + fi + + git checkout gh-pages + target_file="${flavor}/${DEVICE_NAME}.json" + mkdir -p -- "${flavor}" + [[ -f "${DEVICE_NAME}.json" ]] || { + echo "::error::Missing generated OTA metadata for ${DEVICE_NAME}" + exit 1 + } + cp -- "${DEVICE_NAME}.json" "${target_file}" + git add -- "${target_file}" + + if [[ "${{ inputs.release-type }}" == 'force-publish' ]]; then + printf 'force-publish run %s (attempt %s)\n' \ + "${GITHUB_RUN_ID}" "${GITHUB_RUN_ATTEMPT}" > .tmp + git add -- .tmp + fi + if ! git diff-index --quiet HEAD; then + git commit -m "release(${current_commit}): publish ${ROM_FAMILY} ${GRAPHENEOS_VERSION} ${MODULE_SELECTION_FINGERPRINT}" + git push origin gh-pages + fi diff --git a/.github/workflows/phase3-build-only.yml b/.github/workflows/phase3-build-only.yml new file mode 100644 index 00000000..48b8a9f1 --- /dev/null +++ b/.github/workflows/phase3-build-only.yml @@ -0,0 +1,53 @@ +name: Phase 3 build-only acceptance + +on: + workflow_dispatch: + inputs: + rom-family: + description: ROM family to exercise + required: true + type: choice + options: + - grapheneos + - lineageos + default: grapheneos + device-id: + description: Device code name + required: true + default: shiba + root: + description: Add root to the build + required: false + type: boolean + default: false + magisk-preinit-device: + description: Magisk preinit device for rooted builds + required: false + default: sda10 + update-channel: + description: Provider update channel + required: true + default: stable + compatible-sepolicy-patching: + description: Enable compatible SELinux patching + required: false + type: boolean + default: false + +permissions: + contents: read + +jobs: + build-only: + uses: ./.github/workflows/build-rom.yml + with: + rom-family: ${{ inputs.rom-family }} + device-id: ${{ inputs.device-id }} + root: ${{ inputs.root }} + magisk-preinit-device: ${{ inputs.magisk-preinit-device }} + update-channel: ${{ inputs.update-channel }} + compatible-sepolicy-patching: ${{ inputs.compatible-sepolicy-patching }} + allow-unauthorized-adb: false + release-type: build-only + publish: false + secrets: inherit From 4e174dd20348eb47ba0b3b717ff4c0f5aa2531c3 Mon Sep 17 00:00:00 2001 From: 0cwa Date: Sat, 18 Jul 2026 15:23:33 +0200 Subject: [PATCH 08/18] Consolidate ROM release workflows --- .github/workflows/release-lineage.yml | 282 +++---------------------- .github/workflows/release.yml | 287 +++----------------------- tests/phase3_workflows_test.sh | 172 +++++++++++++++ 3 files changed, 222 insertions(+), 519 deletions(-) create mode 100755 tests/phase3_workflows_test.sh diff --git a/.github/workflows/release-lineage.yml b/.github/workflows/release-lineage.yml index 065c8137..b0eef081 100644 --- a/.github/workflows/release-lineage.yml +++ b/.github/workflows/release-lineage.yml @@ -1,10 +1,8 @@ -name: Patch and Release Lineage OTA +name: Patch and Release LineageOS OTA on: schedule: - - cron: "0 */6 * * *" # Check for update every 6 hours (UTC). GrapheneOS checks every 6 hours. - - # Allows you to run this workflow manually from the Actions tab + - cron: "0 */6 * * *" workflow_dispatch: inputs: device-id: @@ -17,24 +15,25 @@ on: type: boolean default: true magisk-preinit-device: - description: Magisk preinit device. For example, "sda8", "sda15" etc., + description: Magisk preinit device required: false default: "sda47" update-channel: - description: LineageOS update channel. Maybe supports `alpha`, `beta`, `stable`, and `nightly`. Defaults to `nightly` + description: LineageOS update channel required: false + default: "nightly" compatible-sepolicy-patching: - description: Enable --compatible-sepolicy flag. Created to enable Lineage Support + description: Enable compatible SELinux patching required: false type: boolean default: true - allow_unauthorized_adb: - description: Enable unauthorized ADB for debugging purposes + allow-unauthorized-adb: + description: Enable unauthorized ADB for build-only debugging required: false type: boolean default: false release-type: - description: 'How to handle the release. `default`: build and publish if new. `build-only`: only build. `force-publish`: build and publish even if it exists.' + description: Release behavior required: true type: choice options: @@ -43,255 +42,20 @@ on: - force-publish default: default -env: - CARGO_INCREMENTAL: 1 - DEVICE_NAME: ${{ github.event.inputs.device-id }} - INTERACTIVE_MODE: false - GRAPHENEOS_UPDATE_CHANNEL: ${{ github.event.inputs.update-channel }} - RUST_BACKTRACE: short - RUSTUP_MAX_RETRIES: 10 - GH_TOKEN: ${{ secrets.GH_TOKEN }} +permissions: + contents: write jobs: build: - runs-on: ubuntu-latest - - # Required by publisher step - permissions: write-all - - steps: - - name: Check if `magisk-preinit-device` is set when `root` is true - run: | - # Convert inputs to proper boolean values - root=${{ github.event.inputs.root }} - magisk_preinit_device=${{ github.event.inputs.magisk-preinit-device }} - - # Ensure that the boolean comparison is correctly handled - if [ "$root" == "true" ] && [ -z "$magisk_preinit_device" ]; then - echo -e "::error:: magisk-preinit-device is required when root is true." - exit 1 - fi - - - name: Checkout code - uses: actions/checkout@v6 - with: - ref: lineage - # Allow for switching to github-pages branch - fetch-depth: 0 - - - name: Read from `env.toml` if exist - if: ${{ github.event_name == 'schedule' }} - run: | - # Check if the file exists - source src/util_functions.sh && check_toml_env - - echo "DEVICE_NAME=${DEVICE_NAME}" >> $GITHUB_ENV - echo "GRAPHENEOS_UPDATE_CHANNEL=${GRAPHENEOS[UPDATE_CHANNEL]}" >> $GITHUB_ENV - - - name: Set GrapheneOS version - shell: bash - run: | - # Device name is a required parameter - if [[ -z "${DEVICE_NAME}" ]]; then - echo -e "::error::Missing required param \`DEVICE_NAME\`" - exit 1 - fi - - # Fetch the latest GrapheneOS version and set up the environment - source src/fetcher.sh && get_latest_version - echo "GRAPHENEOS_VERSION=${VERSION[GRAPHENEOS]}" >> $GITHUB_ENV - - - name: Check if a build exists already and verify assets - shell: bash - if: github.event_name == 'schedule' || github.event.inputs.release-type == 'default' - run: | - # Determine if the build is based on magisk or rootless (build flavor) - build_flavor=$([[ ${{ github.event.inputs.root == 'true' }} == 'true' ]] && echo 'magisk' || echo 'rootless') - - # Check if the tag exists - if git show-ref --tags $GRAPHENEOS_VERSION --quiet; then - echo -e "Tag with GrapheneOS version $GRAPHENEOS_VERSION already exists. Looking for assets..." - # Fetch the release information for the tag - repo_url="https://api.github.com/repos/${{ github.repository }}/releases/tags/${GRAPHENEOS_VERSION}" - release_info=$(curl -sL "$repo_url") - - # Define required assets - required_assets=( - "${{ env.DEVICE_NAME }}-$GRAPHENEOS_VERSION-magisk-*.zip" - "${{ env.DEVICE_NAME }}-$GRAPHENEOS_VERSION-magisk-*.zip.csig" - "${{ env.DEVICE_NAME }}-$GRAPHENEOS_VERSION-rootless-*.zip" - "${{ env.DEVICE_NAME }}-$GRAPHENEOS_VERSION-rootless-*.zip.csig" - ) - - existing_assets=$(echo "$release_info" | jq -r '.assets[].name') - missing_assets=() - - for required_asset in "${required_assets[@]}"; do - # Convert wildcard pattern to regex - regex="${required_asset//\*/.*}" - - for asset in "${existing_assets[@]}"; do - # if existing asset matches the required asset, break the loop - if ! [[ $asset =~ $regex ]]; then - missing_assets+=("$required_asset") - break - fi - done - done - - if [ ${#missing_assets[@]} -eq 0 ]; then - echo -e "::error::All required assets are present. Exiting..." - gh run cancel ${{ github.run_id }} - exit 1 - else - echo -e "Missing assets:" - for missing_asset in "${missing_assets[@]}"; do - echo -e " - $missing_asset" - done - - # Grep always throws an error stating it cannot find the file or directory - valid_build="" - for missing_asset in "${missing_assets[@]}"; do - if [[ $missing_asset == *"$build_flavor"* ]]; then - valid_build="$build_flavor" - break - fi - done - - # Check if valid_build is either "magisk" or "rootless" - if [[ "$valid_build" == "magisk" ]] || [[ "$valid_build" == "rootless" ]]; then - echo -e "Proceeding with build to create missing assets..." - else - echo -e "::error::Asset with \`$build_flavor\` flavor already exists!" - exit 1 - fi - fi - else - echo -e "Tag with GrapheneOS version $GRAPHENEOS_VERSION does not exist. Creating one..." - fi - - - name: Setup Git - run: | - # Configure git for pushing changes - git config --global user.email ${{ secrets.EMAIL }} && git config --global user.name "${{ github.repository_owner }}" - - - name: Install Rust stable - uses: dtolnay/rust-toolchain@master - with: - toolchain: stable 2 weeks ago - - - name: Build and Cache Rust Dependencies - uses: Swatinem/rust-cache@v2.8.2 - - - name: Install Python - uses: actions/setup-python@v6 - with: - python-version: "3.12-dev" - - - name: Setup Environment variables - run: | - echo "KEYS_AVB_BASE64=${{ secrets.AVB_KEY }}" >> $GITHUB_ENV - echo "KEYS_CERT_OTA_BASE64=${{ secrets.CERT_OTA }}" >> $GITHUB_ENV - echo "KEYS_OTA_BASE64=${{ secrets.OTA_KEY }}" >> $GITHUB_ENV - - - name: Block debug ADB release publishing - if: github.event.inputs.allow_unauthorized_adb == 'true' && github.event.inputs.release-type != 'build-only' - run: | - echo "::error::Debug ADB builds must use release-type=build-only. They are labeled debug-adb and must not update normal releases or OTA metadata." - exit 1 - - - name: Patch OTA - shell: bash - env: - ADDITIONALS_ROOT: ${{ github.event.inputs.root }} - ADDITIONALS_DEBUG: ${{ github.event.inputs.allow_unauthorized_adb }} - CLEANUP: true - MAGISK_PREINIT: ${{ github.event.inputs.magisk-preinit-device }} - PASSPHRASE_AVB: ${{ secrets.PASSPHRASE_AVB }} - PASSPHRASE_OTA: ${{ secrets.PASSPHRASE_OTA }} - ADDITIONALS_MAS_COMPATIBLE_SEPOLICY: ${{ github.event.inputs.compatible-sepolicy-patching }} - run: | - echo -e "Running release script.." - - # Instead of running the script directly, - # we source it to get the variables in the current shell and use them in the next steps by exporting them - . src/main.sh - - # Export the variables for the next steps - echo "GRAPHENEOS_OTA_TARGET=${GRAPHENEOS[OTA_TARGET]}" >> $GITHUB_ENV - echo "OUTPUTS_PATCHED_OTA=${OUTPUTS[PATCHED_OTA]}" >> $GITHUB_ENV - echo "WORKDIR=${WORKDIR}" >> $GITHUB_ENV - - - name: Generate Changelog - run: | - # Generate a changelog for the release taking the latest GrapheneOS release - echo -e "See [Changelog](https://grapheneos.org/releases#${{ env.GRAPHENEOS_VERSION }})." > ${{ github.workspace }}-CHANGELOG.txt - - - name: Make Release - uses: softprops/action-gh-release@v2 - if: github.event.inputs.release-type != 'build-only' && github.event.inputs.allow_unauthorized_adb != 'true' - with: - body_path: ${{ github.workspace }}-CHANGELOG.txt - files: | - ${{ env.OUTPUTS_PATCHED_OTA }} - ${{ env.OUTPUTS_PATCHED_OTA }}.csig - name: "${{ env.GRAPHENEOS_VERSION }}" - tag_name: "${{ env.GRAPHENEOS_VERSION }}" - - - name: Publish OTA to server - shell: bash - if: github.event.inputs.release-type != 'build-only' && github.event.inputs.allow_unauthorized_adb != 'true' - run: | - CURRENT_COMMIT=$(git rev-parse --short HEAD) - FLAVOR=("magisk" "rootless") - root=${{ github.event.inputs.root }} - - # Create `magisk` and `rootless` directories if they don't exist - mkdir -p "${FLAVOR}" - - # Switch to gh-pages branch - git checkout gh-pages - echo -e "Updating Configs for the new release..." - - deviceRelease=$(ls *.json) - - # If root is true or the the release has `magisk` in .json, use magisk flavor - if [ "${root}" = "true" ] || grep -q magisk "${deviceRelease}"; then - TARGET_FILE="${FLAVOR[0]}/${deviceRelease}" - else - TARGET_FILE="${FLAVOR[1]}/${deviceRelease}" - fi - - echo -e "Updating Configs for the new release..." - - # Check if the target file exists, if not create an empty one to avoid any issues - if [ ! -f "${TARGET_FILE}" ]; then - echo -e "touching ${TARGET_FILE}..." - touch "${TARGET_FILE}" - fi - - # Copy from `./` to `.//` if same tag doesn't exist in the target file or if it's a force-publish - if [[ "${{ github.event.inputs.release-type }}" == "force-publish" ]] || ! grep -q "${{ env.GRAPHENEOS_VERSION }}" "${TARGET_FILE}"; then - echo -e "Copying ${{ env.DEVICE_NAME }}.json to ${TARGET_FILE}..." - cp "${deviceRelease}" "${TARGET_FILE}" - git add "${TARGET_FILE}" - else - echo -e "Deployed version (${{ env.GRAPHENEOS_VERSION }}) is same as current GrapheneOS release (${{ env.GRAPHENEOS_VERSION }}).\nUpdate skipped." - fi - - # A force-publish can replace the release asset without changing its URL. - # Always update the rebuild marker so gh-pages is pushed and GitHub Pages - # republishes the JSON that points to the most recently published asset. - if [[ "${{ github.event.inputs.release-type }}" == "force-publish" ]]; then - printf 'force-publish run %s (attempt %s)\n' "${GITHUB_RUN_ID}" "${GITHUB_RUN_ATTEMPT}" > .tmp - git add .tmp - fi - - # Commit and push the changes if the working directory has changes - if ! git diff-index --quiet HEAD; then - git commit -m "release("${CURRENT_COMMIT}"): bump GrapheneOS version to ${{ env.GRAPHENEOS_VERSION }}" - git push origin gh-pages - fi - - # Switch back to main branch - git checkout lineage + uses: ./.github/workflows/build-rom.yml + with: + rom-family: lineageos + device-id: ${{ inputs.device-id || 'pdx235' }} + root: ${{ github.event_name == 'workflow_dispatch' && inputs.root || false }} + magisk-preinit-device: ${{ inputs.magisk-preinit-device || 'sda47' }} + update-channel: ${{ inputs.update-channel || 'nightly' }} + compatible-sepolicy-patching: ${{ github.event_name == 'schedule' || inputs.compatible-sepolicy-patching }} + allow-unauthorized-adb: ${{ github.event_name == 'workflow_dispatch' && inputs.allow-unauthorized-adb || false }} + release-type: ${{ github.event_name == 'schedule' && 'default' || inputs.release-type }} + publish: ${{ github.event_name == 'schedule' || inputs.release-type != 'build-only' }} + secrets: inherit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9a28c8b0..e8c36425 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,10 +1,8 @@ -name: Patch and Release OTA +name: Patch and Release GrapheneOS OTA on: schedule: - - cron: "0 */6 * * *" # Check for update every 6 hours (UTC). GrapheneOS checks every 6 hours. - - # Allows you to run this workflow manually from the Actions tab + - cron: "0 */6 * * *" workflow_dispatch: inputs: device-id: @@ -17,19 +15,25 @@ on: type: boolean default: true magisk-preinit-device: - description: Magisk preinit device. For example, "sda8", "sda15" etc., + description: Magisk preinit device required: false default: "sda10" update-channel: - description: GrapheneOS update channel. Supports `alpha`, `beta` and `stable`. Defaults to `stable` + description: GrapheneOS update channel + required: false + default: "stable" + compatible-sepolicy-patching: + description: Enable compatible SELinux patching required: false - allow_unauthorized_adb: - description: Enable unauthorized ADB for debugging purposes + type: boolean + default: false + allow-unauthorized-adb: + description: Enable unauthorized ADB for build-only debugging required: false type: boolean default: false release-type: - description: 'How to handle the release. `default`: build and publish if new. `build-only`: only build. `force-publish`: build and publish even if it exists.' + description: Release behavior required: true type: choice options: @@ -38,257 +42,20 @@ on: - force-publish default: default -env: - CARGO_INCREMENTAL: 1 - DEVICE_NAME: ${{ github.event.inputs.device-id }} - INTERACTIVE_MODE: false - GRAPHENEOS_UPDATE_CHANNEL: ${{ github.event.inputs.update-channel }} - RUST_BACKTRACE: short - RUSTUP_MAX_RETRIES: 10 - GH_TOKEN: ${{ secrets.GH_TOKEN }} #Optional, In order to cancel jobs instead of erroring out when files already exist +permissions: + contents: write jobs: build: - runs-on: ubuntu-latest - - # Required by publisher step - permissions: write-all - - steps: - - name: Check if `magisk-preinit-device` is set when `root` is true - run: | - # Convert inputs to proper boolean values - root=${{ github.event.inputs.root }} - magisk_preinit_device=${{ github.event.inputs.magisk-preinit-device }} - - # Ensure that the boolean comparison is correctly handled - if [ "$root" == "true" ] && [ -z "$magisk_preinit_device" ]; then - echo -e "::error:: magisk-preinit-device is required when root is true." - exit 1 - fi - - - name: Checkout code - uses: actions/checkout@v7 - with: - # Allow for switching to github-pages branch - fetch-depth: 0 - - - name: Read from `env.toml` if exist - if: ${{ github.event_name == 'schedule' }} - run: | - # Check if the file exists - source src/util_functions.sh && check_toml_env - - echo "DEVICE_NAME=${DEVICE_NAME}" >> $GITHUB_ENV - echo "GRAPHENEOS_UPDATE_CHANNEL=${GRAPHENEOS[UPDATE_CHANNEL]}" >> $GITHUB_ENV - - - name: Set GrapheneOS version - shell: bash - run: | - # Device name is a required parameter - if [[ -z "${DEVICE_NAME}" ]]; then - echo -e "::error::Missing required param \`DEVICE_NAME\`" - exit 1 - fi - - # Fetch the latest GrapheneOS version and set up the environment - source src/fetcher.sh && get_latest_version - echo "GRAPHENEOS_VERSION=${VERSION[GRAPHENEOS]}" >> $GITHUB_ENV - - - name: Check if a build exists already and verify assets - shell: bash - if: github.event_name == 'schedule' || github.event.inputs.release-type == 'default' - run: | - # Determine if the build is based on magisk or rootless (build flavor) - build_flavor=$([[ ${{ github.event.inputs.root == 'true' }} == 'true' ]] && echo 'magisk' || echo 'rootless') - - # Check if the tag exists - if git show-ref --tags $GRAPHENEOS_VERSION --quiet; then - echo -e "Tag with GrapheneOS version $GRAPHENEOS_VERSION already exists. Looking for assets..." - # Fetch the release information for the tag - repo_url="https://api.github.com/repos/${{ github.repository }}/releases/tags/$GRAPHENEOS_VERSION" - release_info=$(curl -sL "$repo_url") - - # Define required assets - required_assets=( - "${{ env.DEVICE_NAME }}-$GRAPHENEOS_VERSION-magisk-*.zip" - "${{ env.DEVICE_NAME }}-$GRAPHENEOS_VERSION-magisk-*.zip.csig" - "${{ env.DEVICE_NAME }}-$GRAPHENEOS_VERSION-rootless-*.zip" - "${{ env.DEVICE_NAME }}-$GRAPHENEOS_VERSION-rootless-*.zip.csig" - ) - - existing_assets=$(echo "$release_info" | jq -r '.assets[].name') - missing_assets=() - - for required_asset in "${required_assets[@]}"; do - # Convert wildcard pattern to regex - regex="${required_asset//\*/.*}" - - for asset in "${existing_assets[@]}"; do - # if existing asset matches the required asset, break the loop - if ! [[ $asset =~ $regex ]]; then - missing_assets+=("$required_asset") - break - fi - done - done - - if [ ${#missing_assets[@]} -eq 0 ]; then - echo -e "::error::All required assets are present. Exiting..." - gh run cancel ${{ github.run_id }} - exit 1 - else - echo -e "Missing assets:" - for missing_asset in "${missing_assets[@]}"; do - echo -e " - $missing_asset" - done - - # Grep always throws an error stating it cannot find the file or directory - valid_build="" - for missing_asset in "${missing_assets[@]}"; do - if [[ $missing_asset == *"$build_flavor"* ]]; then - valid_build="$build_flavor" - break - fi - done - - # Check if valid_build is either "magisk" or "rootless" - if [[ "$valid_build" == "magisk" ]] || [[ "$valid_build" == "rootless" ]]; then - echo -e "Proceeding with build to create missing assets..." - else - echo -e "::error::Asset with \`$build_flavor\` flavor already exists!" - exit 1 - fi - fi - else - echo -e "Tag with GrapheneOS version $GRAPHENEOS_VERSION does not exist. Creating one..." - fi - - - name: Setup Git - run: | - # Configure git for pushing changes - git config --global user.email ${{ secrets.EMAIL }} && git config --global user.name "${{ github.repository_owner }}" - - - name: Install Rust stable - uses: dtolnay/rust-toolchain@master - with: - toolchain: stable 2 weeks ago - - - name: Build and Cache Rust Dependencies - uses: Swatinem/rust-cache@v2.9.1 - - - name: Install Python - uses: actions/setup-python@v6 - with: - python-version: "3.12-dev" - - - name: Setup Environment variables - run: | - echo "KEYS_AVB_BASE64<> $GITHUB_ENV - echo "${{ secrets.AVB_KEY }}" >> $GITHUB_ENV - echo "EOF" >> $GITHUB_ENV - echo "KEYS_CERT_OTA_BASE64<> $GITHUB_ENV - echo "${{ secrets.CERT_OTA }}" >> $GITHUB_ENV - echo "EOF" >> $GITHUB_ENV - echo "KEYS_OTA_BASE64<> $GITHUB_ENV - echo "${{ secrets.OTA_KEY }}" >> $GITHUB_ENV - echo "EOF" >> $GITHUB_ENV - - - name: Block debug ADB release publishing - if: github.event.inputs.allow_unauthorized_adb == 'true' && github.event.inputs.release-type != 'build-only' - run: | - echo "::error::Debug ADB builds must use release-type=build-only. They are labeled debug-adb and must not update normal releases or OTA metadata." - exit 1 - - - name: Patch OTA - shell: bash - env: - ADDITIONALS_ROOT: ${{ github.event.inputs.root }} - ADDITIONALS_DEBUG: ${{ github.event.inputs.allow_unauthorized_adb }} - CLEANUP: true - MAGISK_PREINIT: ${{ github.event.inputs.magisk-preinit-device }} - PASSPHRASE_AVB: ${{ secrets.PASSPHRASE_AVB }} - PASSPHRASE_OTA: ${{ secrets.PASSPHRASE_OTA }} - run: | - echo -e "Running release script.." - - # Instead of running the script directly, - # we source it to get the variables in the current shell and use them in the next steps by exporting them - . src/main.sh - - # Export the variables for the next steps - echo "GRAPHENEOS_OTA_TARGET=${GRAPHENEOS[OTA_TARGET]}" >> $GITHUB_ENV - echo "OUTPUTS_PATCHED_OTA=${OUTPUTS[PATCHED_OTA]}" >> $GITHUB_ENV - echo "WORKDIR=${WORKDIR}" >> $GITHUB_ENV - - - name: Generate Changelog - run: | - # Generate a changelog for the release taking the latest GrapheneOS release - echo -e "See [Changelog](https://grapheneos.org/releases#${{ env.GRAPHENEOS_VERSION }})." > ${{ github.workspace }}-CHANGELOG.txt - - - name: Make Release - uses: softprops/action-gh-release@v3 - if: github.event.inputs.release-type != 'build-only' && github.event.inputs.allow_unauthorized_adb != 'true' - with: - body_path: ${{ github.workspace }}-CHANGELOG.txt - files: | - ${{ env.OUTPUTS_PATCHED_OTA }} - ${{ env.OUTPUTS_PATCHED_OTA }}.csig - name: "${{ env.GRAPHENEOS_VERSION }}" - tag_name: "${{ env.GRAPHENEOS_VERSION }}" - - - name: Publish OTA to server - shell: bash - if: github.event.inputs.release-type != 'build-only' && github.event.inputs.allow_unauthorized_adb != 'true' - run: | - CURRENT_COMMIT=$(git rev-parse --short HEAD) - FLAVOR=("magisk" "rootless") - root=${{ github.event.inputs.root }} - - # Create `magisk` and `rootless` directories if they don't exist - mkdir -p "${FLAVOR}" - - # Switch to gh-pages branch - git checkout gh-pages - echo -e "Updating Configs for the new release..." - - # If root is true or the the release has `magisk` in .json, use magisk flavor - if [ "${root}" = "true" ] || grep -q magisk "${{ env.DEVICE_NAME }}.json"; then - TARGET_FILE="${FLAVOR[0]}/${{ env.DEVICE_NAME }}.json" - else - TARGET_FILE="${FLAVOR[1]}/${{ env.DEVICE_NAME }}.json" - fi - - echo -e "Updating Configs for the new release..." - - # Check if the target file exists, if not create an empty one to avoid any issues - if [ ! -f "${TARGET_FILE}" ]; then - echo -e "touching ${TARGET_FILE}..." - touch "${TARGET_FILE}" - fi - - # Copy from `./` to `.//` if same tag doesn't exist in the target file or if it's a force-publish - if [[ "${{ github.event.inputs.release-type }}" == "force-publish" ]] || ! grep -q "${{ env.GRAPHENEOS_VERSION }}" "${TARGET_FILE}"; then - echo -e "Copying ${{ env.DEVICE_NAME }}.json to ${TARGET_FILE}..." - cp "${{ env.DEVICE_NAME }}.json" "${TARGET_FILE}" - git add "${TARGET_FILE}" - else - echo -e "Deployed version (${{ env.GRAPHENEOS_VERSION }}) is same as current GrapheneOS release (${{ env.GRAPHENEOS_VERSION }}).\nUpdate skipped." - fi - - # A force-publish can replace the release asset without changing its URL. - # Always update the rebuild marker so gh-pages is pushed and GitHub Pages - # republishes the JSON that points to the most recently published asset. - if [[ "${{ github.event.inputs.release-type }}" == "force-publish" ]]; then - printf 'force-publish run %s (attempt %s)\n' "${GITHUB_RUN_ID}" "${GITHUB_RUN_ATTEMPT}" > .tmp - git add .tmp - fi - - # Commit and push the changes if the working directory has changes - if ! git diff-index --quiet HEAD; then - git commit -m "release("${CURRENT_COMMIT}"): bump GrapheneOS version to ${{ env.GRAPHENEOS_VERSION }}" - git push origin gh-pages - fi - - # Switch back to main branch - git checkout main + uses: ./.github/workflows/build-rom.yml + with: + rom-family: grapheneos + device-id: ${{ github.event_name == 'schedule' && 'bramble' || inputs.device-id }} + root: ${{ github.event_name == 'workflow_dispatch' && inputs.root || false }} + magisk-preinit-device: ${{ inputs.magisk-preinit-device || 'sda10' }} + update-channel: ${{ inputs.update-channel || 'stable' }} + compatible-sepolicy-patching: ${{ github.event_name == 'workflow_dispatch' && inputs.compatible-sepolicy-patching || false }} + allow-unauthorized-adb: ${{ github.event_name == 'workflow_dispatch' && inputs.allow-unauthorized-adb || false }} + release-type: ${{ github.event_name == 'schedule' && 'default' || inputs.release-type }} + publish: ${{ github.event_name == 'schedule' || inputs.release-type != 'build-only' }} + secrets: inherit diff --git a/tests/phase3_workflows_test.sh b/tests/phase3_workflows_test.sh new file mode 100755 index 00000000..fb418cb7 --- /dev/null +++ b/tests/phase3_workflows_test.sh @@ -0,0 +1,172 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2024-2026 PixeneOS contributors + +set -euo pipefail + +WORKFLOW_DIR=".github/workflows" +REUSABLE="${WORKFLOW_DIR}/build-rom.yml" + +fail() { + echo "$*" >&2 + exit 1 +} + +assert_contains() { + local file="${1}" + local pattern="${2}" + local context="${3}" + + grep -Eq -- "${pattern}" "${file}" || + fail "${context}: ${file} does not match ${pattern}" +} + +assert_not_contains() { + local file="${1}" + local pattern="${2}" + local context="${3}" + + if grep -Eq -- "${pattern}" "${file}"; then + fail "${context}: ${file} unexpectedly matches ${pattern}" + fi +} + +assert_thin_trigger() { + local file="${1}" + local family="${2}" + + [[ -f "${file}" ]] || fail "missing ${family} release trigger: ${file}" + assert_contains \ + "${file}" \ + 'uses:[[:space:]]*\./\.github/workflows/build-rom\.yml' \ + "${family} trigger must call the shared workflow" + assert_contains \ + "${file}" \ + "rom-family:[[:space:]]*['\"]?${family}['\"]?" \ + "${family} trigger must select its ROM family" + assert_not_contains \ + "${file}" \ + 'uses:[[:space:]]*actions/checkout@' \ + "thin triggers must not duplicate checkout/build steps" + assert_not_contains \ + "${file}" \ + '(^|[[:space:]])ref:[[:space:]]*['\"]?lineage['\"]?([[:space:]#]|$)' \ + "release triggers must not check out a divergent lineage branch" +} + +assert_dispatch_default() { + local file="${1}" + local input="${2}" + local expected="${3}" + local actual + + actual="$(awk -v input="${input}" ' + $0 ~ "^[[:space:]]{6}" input ":[[:space:]]*$" { in_input = 1; next } + in_input && $0 ~ "^[[:space:]]{6}[A-Za-z0-9_-]+:[[:space:]]*$" { exit } + in_input && $0 ~ "^[[:space:]]+default:[[:space:]]*" { + sub(/^.*default:[[:space:]]*/, "") + gsub(/[[:space:]\047\"]/, "") + print + exit + } + ' "${file}")" + + [[ "${actual}" == "${expected}" ]] || + fail "${file}: ${input} default expected ${expected}, got ${actual:-missing}" +} + +find_manual_acceptance_workflow() { + local file + + for file in "${WORKFLOW_DIR}"/*.yml "${WORKFLOW_DIR}"/*.yaml; do + [[ -f "${file}" ]] || continue + [[ "${file}" == "${REUSABLE}" ]] && continue + [[ "${file}" == "${WORKFLOW_DIR}/release.yml" ]] && continue + [[ "${file}" == "${WORKFLOW_DIR}/release-lineage.yml" ]] && continue + if grep -Eq 'workflow_dispatch:' "${file}" && + grep -Eq 'uses:[[:space:]]*\./\.github/workflows/build-rom\.yml' "${file}" && + grep -Eqi 'build-only|publish:[[:space:]]*false' "${file}"; then + printf '%s\n' "${file}" + return 0 + fi + done + + return 1 +} + +test_reusable_workflow() { + [[ -f "${REUSABLE}" ]] || fail "missing reusable ROM workflow: ${REUSABLE}" + assert_contains \ + "${REUSABLE}" \ + 'workflow_call:' \ + "shared ROM workflow must be reusable" + assert_contains \ + "${REUSABLE}" \ + 'rom-family:' \ + "shared ROM workflow must accept a ROM family" + assert_contains \ + "${REUSABLE}" \ + 'uses:[[:space:]]*actions/checkout@' \ + "shared ROM workflow must own checkout" + assert_contains \ + "${REUSABLE}" \ + 'MODULE_SELECTION_FINGERPRINT' \ + "shared workflow must retain the full selection fingerprint as metadata" + assert_contains \ + "${REUSABLE}" \ + 'OUTPUT_SCOPE' \ + "shared workflow must set an explicit output scope" + assert_contains \ + "${REUSABLE}" \ + 'enforce_output_policy' \ + "shared workflow must enforce policy before release or upload" + assert_not_contains \ + "${REUSABLE}" \ + '(^|[[:space:]])ref:[[:space:]]*['\"]?lineage['\"]?([[:space:]#]|$)' \ + "shared ROM workflow must not check out a divergent lineage branch" +} + +test_release_triggers() { + assert_thin_trigger "${WORKFLOW_DIR}/release.yml" grapheneos + assert_thin_trigger "${WORKFLOW_DIR}/release-lineage.yml" lineageos + + assert_dispatch_default "${WORKFLOW_DIR}/release.yml" device-id shiba + assert_dispatch_default "${WORKFLOW_DIR}/release.yml" root true + assert_dispatch_default \ + "${WORKFLOW_DIR}/release.yml" compatible-sepolicy-patching false + assert_dispatch_default \ + "${WORKFLOW_DIR}/release-lineage.yml" device-id pdx235 + assert_dispatch_default "${WORKFLOW_DIR}/release-lineage.yml" root true + assert_dispatch_default \ + "${WORKFLOW_DIR}/release-lineage.yml" compatible-sepolicy-patching true +} + +test_manual_build_only_acceptance() { + local acceptance + + acceptance="$(find_manual_acceptance_workflow)" || + fail "missing manual build-only workflow that calls build-rom.yml" + assert_contains \ + "${acceptance}" \ + 'workflow_dispatch:' \ + "acceptance workflow must be manually dispatched" +} + +test_no_lineage_checkout_anywhere() { + local file + + while IFS= read -r -d '' file; do + assert_not_contains \ + "${file}" \ + '(^|[[:space:]])ref:[[:space:]]*['\"]?lineage['\"]?([[:space:]#]|$)' \ + "workflows must use the main branch implementation" + done < <(find "${WORKFLOW_DIR}" -maxdepth 1 -type f \ + \( -name '*.yml' -o -name '*.yaml' \) -print0) +} + +test_reusable_workflow +test_release_triggers +test_manual_build_only_acceptance +test_no_lineage_checkout_anywhere + +echo "Phase 3 workflow tests passed" From 069fd4a2e5d72e7f5bbd3dfe60c6ee6638694b86 Mon Sep 17 00:00:00 2001 From: 0cwa Date: Sat, 18 Jul 2026 15:36:32 +0200 Subject: [PATCH 09/18] Close Phase 3 publication and collision gaps --- .github/workflows/build-rom.yml | 52 ++++++++++++++++------- src/declarations.sh | 1 + src/fetcher.sh | 3 +- src/ota_providers.sh | 29 ++++++++++++- src/rom_profiles.sh | 34 +++++++++++++++ src/util_functions.sh | 6 +-- tests/phase3_provider_acquisition_test.sh | 41 ++++++++++++++++++ tests/phase3_rom_contract_test.sh | 17 ++++++-- tests/phase3_workflows_test.sh | 10 ++++- 9 files changed, 168 insertions(+), 25 deletions(-) diff --git a/.github/workflows/build-rom.yml b/.github/workflows/build-rom.yml index 33d96ca2..174e4ddd 100644 --- a/.github/workflows/build-rom.yml +++ b/.github/workflows/build-rom.yml @@ -57,6 +57,7 @@ env: RUST_BACKTRACE: short RUSTUP_MAX_RETRIES: 10 GH_TOKEN: ${{ secrets.GH_TOKEN }} + RELEASE_TYPE: ${{ inputs.release-type }} jobs: build: @@ -71,12 +72,13 @@ jobs: ADDITIONALS_DEBUG: ${{ inputs.allow-unauthorized-adb }} ADDITIONALS_MAS_COMPATIBLE_SEPOLICY: ${{ inputs.compatible-sepolicy-patching }} ADDITIONALS_ROOT: ${{ inputs.root }} + MAGISK_PREINIT_REQUEST: ${{ inputs.magisk-preinit-device }} run: | - if [[ "${ADDITIONALS_ROOT}" == "true" && -z "${{ inputs.magisk-preinit-device }}" ]]; then + if [[ "${ADDITIONALS_ROOT}" == "true" && -z "${MAGISK_PREINIT_REQUEST}" ]]; then echo "::error::magisk-preinit-device is required for rooted builds" exit 1 fi - case "${{ inputs.release-type }}" in + case "${RELEASE_TYPE}" in default|build-only|force-publish) ;; *) echo "::error::Unknown release type"; exit 1 ;; esac @@ -147,6 +149,31 @@ jobs: - name: Record build metadata shell: bash run: | + selection_metadata="${OUTPUTS_PATCHED_OTA}.selection.json" + python3 - \ + "${selection_metadata}" \ + "${ROM_FAMILY}" \ + "${DEVICE_NAME}" \ + "${MODULE_SELECTION_FINGERPRINT}" \ + "${OUTPUT_SCOPE}" <<'PY' + import json + import pathlib + import sys + + path, rom_family, device, fingerprint, output_scope = sys.argv[1:] + data = { + "device": device, + "module_selection_fingerprint": fingerprint, + "output_scope": output_scope, + "rom_family": rom_family, + "schema_version": 1, + } + pathlib.Path(path).write_text( + json.dumps(data, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + PY + echo "SELECTION_METADATA=${selection_metadata}" >> "${GITHUB_ENV}" { echo "ROM family: ${ROM_FAMILY}" echo "Device: ${DEVICE_NAME}" @@ -154,16 +181,6 @@ jobs: echo "Output scope: ${OUTPUT_SCOPE}" } >> "${GITHUB_STEP_SUMMARY}" - - name: Upload build-only artifact - if: inputs.publish == false || inputs.release-type == 'build-only' - uses: actions/upload-artifact@v4 - with: - name: ${{ inputs.rom-family }}-${{ inputs.device-id }}-${{ env.MODULE_SELECTION_FINGERPRINT }} - if-no-files-found: error - path: | - ${{ env.OUTPUTS_PATCHED_OTA }} - ${{ env.OUTPUTS_PATCHED_OTA }}.csig - - name: Re-enforce publication policy if: inputs.publish && inputs.release-type != 'build-only' shell: bash @@ -175,7 +192,7 @@ jobs: source src/declarations.sh source src/rom_profiles.sh resolve_rom_profile - enforce_output_policy "${OUTPUT_SCOPE}" + enforce_publication_evidence "${OUTPUT_SCOPE}" [[ "${MODULE_SELECTION_FINGERPRINT}" =~ ^[0-9a-f]{64}$ ]] - name: Generate changelog @@ -196,6 +213,7 @@ jobs: files: | ${{ env.OUTPUTS_PATCHED_OTA }} ${{ env.OUTPUTS_PATCHED_OTA }}.csig + ${{ env.SELECTION_METADATA }} name: ${{ env.GRAPHENEOS_VERSION }} tag_name: ${{ env.GRAPHENEOS_VERSION }} @@ -214,15 +232,17 @@ jobs: git checkout gh-pages target_file="${flavor}/${DEVICE_NAME}.json" - mkdir -p -- "${flavor}" + variant_file="variants/${ROM_FAMILY}/${flavor}/${DEVICE_NAME}-${MODULE_SELECTION_FINGERPRINT}.json" + mkdir -p -- "${flavor}" "$(dirname -- "${variant_file}")" [[ -f "${DEVICE_NAME}.json" ]] || { echo "::error::Missing generated OTA metadata for ${DEVICE_NAME}" exit 1 } cp -- "${DEVICE_NAME}.json" "${target_file}" - git add -- "${target_file}" + cp -- "${DEVICE_NAME}.json" "${variant_file}" + git add -- "${target_file}" "${variant_file}" - if [[ "${{ inputs.release-type }}" == 'force-publish' ]]; then + if [[ "${RELEASE_TYPE}" == 'force-publish' ]]; then printf 'force-publish run %s (attempt %s)\n' \ "${GITHUB_RUN_ID}" "${GITHUB_RUN_ATTEMPT}" > .tmp git add -- .tmp diff --git a/src/declarations.sh b/src/declarations.sh index 82e50402..5c39a8c7 100755 --- a/src/declarations.sh +++ b/src/declarations.sh @@ -24,6 +24,7 @@ INTERACTIVE_MODE="${INTERACTIVE_MODE:-true}" # Enable interactive mode ROM_FAMILY="${ROM_FAMILY:-grapheneos}" OUTPUT_SCOPE="${OUTPUT_SCOPE:-local-unpublished}" MODULE_SELECTION_FINGERPRINT="${MODULE_SELECTION_FINGERPRINT:-}" +ROM_OTA_SHA256="${ROM_OTA_SHA256:-}" WORKDIR=".tmp" # GitHub variables diff --git a/src/fetcher.sh b/src/fetcher.sh index 3caeb27a..ef7e0899 100755 --- a/src/fetcher.sh +++ b/src/fetcher.sh @@ -91,9 +91,10 @@ function download_ota() { # Download if not downloaded already if [ ! -f "${ota}" ]; then echo -e "Downloading OTA from: ${GRAPHENEOS[OTA_URL]}...\nPlease be patient while the download happens." - curl -sL "${GRAPHENEOS[OTA_URL]}" --output "${ota}" + curl -sLf "${GRAPHENEOS[OTA_URL]}" --output "${ota}" echo -e "OTA downloaded to: \`${ota}\`\n" else echo -e "OTA is already downloaded in: \`${ota}\`\n" fi + verify_rom_ota_digest "${ota}" } diff --git a/src/ota_providers.sh b/src/ota_providers.sh index 9ea26933..dd037036 100644 --- a/src/ota_providers.sh +++ b/src/ota_providers.sh @@ -17,6 +17,7 @@ function fetch_grapheneos_ota_metadata() { fi VERSION[GRAPHENEOS]="${GRAPHENEOS_VERSION:-${latest_version}}" + ROM_OTA_SHA256="" GRAPHENEOS[OTA_TARGET]="${DEVICE_NAME}-${GRAPHENEOS[UPDATE_TYPE]}-${latest_version}" GRAPHENEOS[OTA_URL]="${ROM_PROFILE[OTA_BASE_URL]}/${GRAPHENEOS[OTA_TARGET]}.zip" } @@ -46,10 +47,13 @@ try: ) filename = item["filename"] url = item["url"] + sha256 = item["sha256"] except (KeyError, IndexError, StopIteration, TypeError, ValueError, json.JSONDecodeError): raise SystemExit(1) if not isinstance(filename, str) or not re.fullmatch(r"[A-Za-z0-9._+-]+[.]zip", filename): raise SystemExit(1) +if not isinstance(sha256, str) or not re.fullmatch(r"[0-9a-f]{64}", sha256): + raise SystemExit(1) if not re.fullmatch(r"[a-z0-9_]+", device) or f"-{device}-" not in filename: raise SystemExit(1) parts = urlsplit(url) @@ -62,12 +66,15 @@ if ( raise SystemExit(1) print(filename) print(url) +print(sha256) ' "${GRAPHENEOS[UPDATE_CHANNEL]}" "${DEVICE_NAME}")" || { echo "Error: LineageOS returned invalid release metadata." >&2 return 1 } filename="${parsed%%$'\n'*}" - ota_url="${parsed#*$'\n'}" + parsed="${parsed#*$'\n'}" + ota_url="${parsed%%$'\n'*}" + ROM_OTA_SHA256="${parsed#*$'\n'}" latest_version="$(printf '%s\n' "${filename}" | sed -nE 's/.*(^|[^0-9])([0-9]{8})([^0-9]|$).*/\2/p')" if [[ ! "${latest_version}" =~ ^[0-9]{8}$ ]]; then echo "Error: LineageOS filename lacks an unambiguous build date." >&2 @@ -80,6 +87,8 @@ print(url) } function fetch_rom_ota_metadata() { + validate_device_name || return 1 + case "${ROM_PROFILE[PROVIDER]}" in grapheneos) fetch_grapheneos_ota_metadata ;; lineageos) fetch_lineageos_ota_metadata ;; @@ -89,3 +98,21 @@ function fetch_rom_ota_metadata() { ;; esac } + +function verify_rom_ota_digest() { + local ota_path="${1}" + local actual_digest + + if [[ "${ROM_PROFILE[PROVIDER]}" != 'lineageos' ]]; then + return 0 + fi + if [[ ! "${ROM_OTA_SHA256}" =~ ^[0-9a-f]{64}$ ]]; then + echo "Error: LineageOS OTA metadata lacks a valid SHA-256." >&2 + return 1 + fi + actual_digest="$(sha256sum -- "${ota_path}" | awk '{print $1}')" || return 1 + if [[ "${actual_digest}" != "${ROM_OTA_SHA256}" ]]; then + echo "Error: downloaded LineageOS OTA SHA-256 does not match metadata." >&2 + return 1 + fi +} diff --git a/src/rom_profiles.sh b/src/rom_profiles.sh index 84cc8e2c..35a75800 100644 --- a/src/rom_profiles.sh +++ b/src/rom_profiles.sh @@ -16,6 +16,13 @@ function _require_profile_boolean() { fi } +function validate_device_name() { + if [[ ! "${DEVICE_NAME}" =~ ^[a-z0-9_]+$ ]]; then + echo "Error: invalid device name." >&2 + return 1 + fi +} + function resolve_rom_profile() { case "${ROM_FAMILY}" in grapheneos) @@ -84,6 +91,23 @@ function enforce_output_policy() { fi } +function enforce_publication_evidence() { + local output_scope="${1}" + + if [[ "${output_scope}" != 'published' ]]; then + echo "Error: publication requires the published output scope." >&2 + return 1 + fi + enforce_output_policy "${output_scope}" || return 1 + + # No locked adapter currently has a reviewed source-delivery publication + # path. The helper report remains authoritative once such a path exists. + if [[ "${ADDITIONALS[FDROID_PRIVILEGED_EXTENSION]}" == 'true' ]]; then + echo "Error: locked-module publication evidence is unavailable." >&2 + return 1 + fi +} + function _locked_input_digest() { local input_path="${1}" @@ -97,6 +121,7 @@ function _locked_input_digest() { function module_selection_fingerprint() { local lock_digest="disabled" local profile_digest="disabled" + local magisk_preinit="disabled" local entry local -a module_entries=( "alterinstaller:ALTERINSTALLER" @@ -118,6 +143,14 @@ function module_selection_fingerprint() { "${ADDITIONALS[${entry#*:}]}" || return 1 done + if [[ "${ADDITIONALS[ROOT]}" == 'true' ]]; then + magisk_preinit="${MAGISK[PREINIT]}" + if [[ ! "${magisk_preinit}" =~ ^[A-Za-z0-9._-]+$ ]]; then + echo "Error: rooted profiles require a canonical Magisk preinit device." >&2 + return 1 + fi + fi + if [[ "${ADDITIONALS[FDROID_PRIVILEGED_EXTENSION]}" == 'true' ]]; then lock_digest="$(_locked_input_digest "${FDROID_PRIVILEGED_EXTENSION_LOCK}")" || { echo "Error: the F-Droid lock is not clean and checked in." >&2 @@ -135,6 +168,7 @@ function module_selection_fingerprint() { "rom_family=${ROM_FAMILY}" \ "output_scope=${OUTPUT_SCOPE}" \ "root=${ADDITIONALS[ROOT]}" \ + "magisk_preinit=${magisk_preinit}" \ "debug=${ADDITIONALS[DEBUG]}" \ "compatible_sepolicy=${ADDITIONALS[MAS_COMPATIBLE_SEPOLICY]}" \ "clear_vbmeta_flags=${ROM_PROFILE[CLEAR_VBMETA_FLAGS]}" \ diff --git a/src/util_functions.sh b/src/util_functions.sh index 85c6833d..b9d8bf19 100755 --- a/src/util_functions.sh +++ b/src/util_functions.sh @@ -644,6 +644,8 @@ function make_directories() { } function generate_ota_info() { + validate_device_name + # Detect build flavor local flavor=$([[ ${ADDITIONALS[ROOT]} == 'true' ]] && echo "magisk-${VERSION[MAGISK]}" || echo "rootless") local debug_suffix="" @@ -653,11 +655,9 @@ function generate_ota_info() { fi module_selection_fingerprint >/dev/null - local fingerprint_short="${MODULE_SELECTION_FINGERPRINT:0:16}" - # Debug builds are intentionally labeled. The stable selection fingerprint # prevents otherwise identical ROM/profile variants from colliding. - OUTPUTS[PATCHED_OTA]="${DEVICE_NAME}-${VERSION[GRAPHENEOS]}-${flavor}${debug_suffix}-${fingerprint_short}-$(git rev-parse --short HEAD)$(dirty_suffix).zip" + OUTPUTS[PATCHED_OTA]="${DEVICE_NAME}-${VERSION[GRAPHENEOS]}-${flavor}${debug_suffix}-${MODULE_SELECTION_FINGERPRINT}-$(git rev-parse --short HEAD)$(dirty_suffix).zip" } function check_toml_env() { diff --git a/tests/phase3_provider_acquisition_test.sh b/tests/phase3_provider_acquisition_test.sh index 3031e909..4415fd1f 100755 --- a/tests/phase3_provider_acquisition_test.sh +++ b/tests/phase3_provider_acquisition_test.sh @@ -39,6 +39,7 @@ reset_acquisition_fixture() { VERSION[MAGISK]="" GRAPHENEOS[OTA_URL]="" GRAPHENEOS[OTA_TARGET]="" + ROM_OTA_SHA256="" ADDITIONALS_MAS_COMPATIBLE_SEPOLICY="" } @@ -65,6 +66,7 @@ test_grapheneos_text_metadata() ( "https://releases.grapheneos.org/shiba-ota_update-2026071700.zip" \ "${GRAPHENEOS[OTA_URL]}" "GrapheneOS OTA URL" assert_equals "v29.0" "${VERSION[MAGISK]}" "Magisk version" + assert_equals "" "${ROM_OTA_SHA256}" "GrapheneOS digest availability" ) test_lineageos_v2_metadata() ( @@ -109,6 +111,30 @@ test_lineageos_v2_metadata() ( "https://mirrorbits.lineageos.org/full/pdx235/20260717/lineage-23.2-20260717-nightly-pdx235-signed.zip" \ "${GRAPHENEOS[OTA_URL]}" "LineageOS OTA URL" assert_equals "v29.0" "${VERSION[MAGISK]}" "Magisk version" + assert_equals \ + "df27d06052a79f0acc24e8862b70a0c32f188e4a6f107964c93bdb54ade7accc" \ + "${ROM_OTA_SHA256}" "LineageOS OTA SHA-256" +) + +test_lineageos_digest_verification() ( + local ota_path actual + + reset_acquisition_fixture + ROM_FAMILY="lineageos" + DEVICE_NAME="pdx235" + resolve_rom_profile + ota_path="$(mktemp)" + trap 'rm -f -- "${ota_path}"' EXIT + printf '%s' 'fixture OTA bytes' >"${ota_path}" + actual="$(sha256sum -- "${ota_path}" | awk '{print $1}')" + ROM_OTA_SHA256="${actual}" + verify_rom_ota_digest "${ota_path}" || + fail "matching LineageOS OTA digest was rejected" + + ROM_OTA_SHA256="$(printf '0%.0s' {1..64})" + if verify_rom_ota_digest "${ota_path}" >/dev/null 2>&1; then + fail "mismatched LineageOS OTA digest unexpectedly succeeded" + fi ) test_invalid_grapheneos_metadata_fails_closed() ( @@ -124,6 +150,19 @@ test_invalid_grapheneos_metadata_fails_closed() ( fi ) +test_unsafe_grapheneos_device_fails_closed() ( + reset_acquisition_fixture + ROM_FAMILY="grapheneos" + DEVICE_NAME="../shiba" + + git() { mock_magisk_tags "$@"; } + curl() { fail "unsafe GrapheneOS device reached the network"; } + + if get_latest_version >/dev/null 2>&1; then + fail "unsafe GrapheneOS device unexpectedly succeeded" + fi +) + test_unsafe_lineageos_metadata_fails_closed() ( reset_acquisition_fixture ROM_FAMILY="lineageos" @@ -149,7 +188,9 @@ test_unsafe_lineageos_metadata_fails_closed() ( test_grapheneos_text_metadata test_lineageos_v2_metadata +test_lineageos_digest_verification test_invalid_grapheneos_metadata_fails_closed +test_unsafe_grapheneos_device_fails_closed test_unsafe_lineageos_metadata_fails_closed echo "Phase 3 provider acquisition tests passed" diff --git a/tests/phase3_rom_contract_test.sh b/tests/phase3_rom_contract_test.sh index 7c6f99fc..ca184c00 100755 --- a/tests/phase3_rom_contract_test.sh +++ b/tests/phase3_rom_contract_test.sh @@ -116,11 +116,18 @@ test_selection_fingerprint() ( repeated="$(fingerprint)" assert_equals "${baseline}" "${repeated}" "deterministic fingerprint" + MAGISK[PREINIT]="sda10" ADDITIONALS[ROOT]="true" root_changed="$(fingerprint)" [[ "${root_changed}" != "${baseline}" ]] || fail "root selection did not change the fingerprint" + MAGISK[PREINIT]="sda47" + local preinit_changed + preinit_changed="$(fingerprint)" + [[ "${preinit_changed}" != "${root_changed}" ]] || + fail "Magisk preinit selection did not change the fingerprint" + ADDITIONALS[ROOT]="false" ROM_FAMILY="lineageos" resolve_rom_profile @@ -137,14 +144,13 @@ test_selection_fingerprint() ( ) test_output_filename_contains_fingerprint() ( - local full compact + local full load_contract set_selection_fixture DEVICE_NAME="shiba" VERSION[GRAPHENEOS]="2026071700" full="$(fingerprint)" - compact="${full:0:16}" git() { if [[ "${1:-}" == "rev-parse" ]]; then @@ -156,7 +162,7 @@ test_output_filename_contains_fingerprint() ( dirty_suffix() { :; } generate_ota_info - [[ "${OUTPUTS[PATCHED_OTA]}" == *"-${compact}-"* ]] || + [[ "${OUTPUTS[PATCHED_OTA]}" == *"-${full}-"* ]] || fail "output filename does not include selection fingerprint: ${OUTPUTS[PATCHED_OTA]}" ) @@ -170,6 +176,8 @@ test_output_policy() ( enforce_output_policy "${scope}" >/dev/null || fail "disabled legacy profile rejected allowed scope ${scope}" done + enforce_publication_evidence published >/dev/null || + fail "legacy profile rejected publication evidence gate" ADDITIONALS[FDROID_PRIVILEGED_EXTENSION]="true" enforce_output_policy local-unpublished >/dev/null || @@ -179,6 +187,9 @@ test_output_policy() ( fail "F-Droid profile unexpectedly allowed ${scope} output" fi done + if enforce_publication_evidence published >/dev/null 2>&1; then + fail "F-Droid profile unexpectedly passed publication evidence gate" + fi if enforce_output_policy unknown-scope >/dev/null 2>&1; then fail "unknown output scope unexpectedly passed policy enforcement" diff --git a/tests/phase3_workflows_test.sh b/tests/phase3_workflows_test.sh index fb418cb7..f00a03f4 100755 --- a/tests/phase3_workflows_test.sh +++ b/tests/phase3_workflows_test.sh @@ -65,7 +65,7 @@ assert_dispatch_default() { in_input && $0 ~ "^[[:space:]]{6}[A-Za-z0-9_-]+:[[:space:]]*$" { exit } in_input && $0 ~ "^[[:space:]]+default:[[:space:]]*" { sub(/^.*default:[[:space:]]*/, "") - gsub(/[[:space:]\047\"]/, "") + gsub(/[[:space:]\047"]/, "") print exit } @@ -120,6 +120,14 @@ test_reusable_workflow() { "${REUSABLE}" \ 'enforce_output_policy' \ "shared workflow must enforce policy before release or upload" + assert_contains \ + "${REUSABLE}" \ + 'enforce_publication_evidence' \ + "shared workflow must enforce publication evidence" + assert_not_contains \ + "${REUSABLE}" \ + 'actions/upload-artifact@' \ + "local-unpublished outputs must not be uploaded" assert_not_contains \ "${REUSABLE}" \ '(^|[[:space:]])ref:[[:space:]]*['\"]?lineage['\"]?([[:space:]#]|$)' \ From cba539b1eb43f0b21514c3e3aa8f91c76f6f3e72 Mon Sep 17 00:00:00 2001 From: 0cwa Date: Sun, 19 Jul 2026 19:50:05 +0200 Subject: [PATCH 10/18] Revert "resolve merge conflict" This reverts commit f715e9ba6e5cfa4fe70bf901380dd58e64d03f30, reversing changes made to 069fd4a2e5d72e7f5bbd3dfe60c6ee6638694b86. --- src/declarations.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/declarations.sh b/src/declarations.sh index d70e79ad..5c39a8c7 100755 --- a/src/declarations.sh +++ b/src/declarations.sh @@ -39,10 +39,10 @@ PIXENEOS_AVBROOT_SETUP_SOURCE="${PIXENEOS_AVBROOT_SETUP_SOURCE:-}" # Application version variables VERSION[AFSR]="${VERSION[AFSR]:-1.0.4}" VERSION[ALTERINSTALLER]="${VERSION[ALTERINSTALLER]:-2.4}" -VERSION[AVBROOT]="${VERSION[AVBROOT]:-3.32.0}" -VERSION[AVBROOT_SETUP]="e4f80bb54aa5ae8de6109edd7d0873d5b4966748" # Commit hash +VERSION[AVBROOT]="${VERSION[AVBROOT]:-3.31.0}" +VERSION[AVBROOT_SETUP]="09d32371829fb3b34455edbd2fee58fd84db613c" # Commit hash VERSION[BCR]="${VERSION[BCR]:-3.4}" -VERSION[CUSTOTA]="${VERSION[CUSTOTA]:-6.3}" +VERSION[CUSTOTA]="${VERSION[CUSTOTA]:-6.2}" VERSION[GRAPHENEOS]="${VERSION[GRAPHENEOS]:-}" VERSION[MAGISK]="${VERSION[MAGISK]:-}" VERSION[MSD]="${VERSION[MSD]:-2.3}" From 270528f1f023724523925d48f052667e3940afcb Mon Sep 17 00:00:00 2001 From: 0cwa Date: Sun, 19 Jul 2026 19:52:49 +0200 Subject: [PATCH 11/18] Fix secrets scan false positives --- .gitleaks.toml | 4 +-- .gitleaksignore | 5 ++++ src/scan_secrets.sh | 8 ++--- tests/secrets_scan_test.sh | 61 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 72 insertions(+), 6 deletions(-) create mode 100644 .gitleaksignore create mode 100755 tests/secrets_scan_test.sh diff --git a/.gitleaks.toml b/.gitleaks.toml index 11927cb7..69e7d806 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -12,14 +12,14 @@ tags = ["pixeneos", "signing", "key"] [[rules]] id = "pixeneos-base64-signing-secret" description = "PixeneOS base64 signing secrets belong in GitHub secrets, not source files" -regex = '''(?i)\b(KEYS_AVB_BASE64|KEYS_CERT_OTA_BASE64|KEYS_OTA_BASE64|AVB_KEY|CERT_OTA|OTA_KEY)\b\s*[:=]\s*["']?([A-Za-z0-9+/]{40,}={0,2})''' +regex = '''(?i)\b(KEYS_AVB_BASE64|KEYS_CERT_OTA_BASE64|KEYS_OTA_BASE64|AVB_KEY|CERT_OTA|OTA_KEY)\b[ \t]*[:=][ \t]*["']?([A-Za-z0-9+/]{40,}={0,2})''' secretGroup = 2 tags = ["pixeneos", "signing", "base64"] [[rules]] id = "pixeneos-signing-passphrase" description = "PixeneOS signing passphrases belong in GitHub secrets, not source files" -regex = '''(?i)\b(PASSPHRASE_AVB|PASSPHRASE_OTA)\b\s*[:=]\s*["']?([^$\s"'][^\s"']{7,})''' +regex = '''(?i)\b(PASSPHRASE_AVB|PASSPHRASE_OTA)\b[ \t]*[:=][ \t]*["']?([^$\r\n\t "'][^\r\n\t "']{7,})''' secretGroup = 2 tags = ["pixeneos", "signing", "passphrase"] diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 00000000..b44fe686 --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1,5 @@ +# These exact historical findings are variable names for expected SSH signature +# messages, not API keys. Keep the exceptions bound to their original commit, +# path, rule, and line so later findings remain fail-closed. +97c0d147a50d0cf5e2714089cd35acd0e205d578:src/util_functions.sh:generic-api-key:30 +97c0d147a50d0cf5e2714089cd35acd0e205d578:src/util_functions.sh:generic-api-key:40 diff --git a/src/scan_secrets.sh b/src/scan_secrets.sh index 2fb965a9..9c766de2 100755 --- a/src/scan_secrets.sh +++ b/src/scan_secrets.sh @@ -37,9 +37,9 @@ run_gitleaks() { fi if [[ "${mode}" == "staged" ]]; then - gitleaks protect --staged --redact --config "${config}" + gitleaks git --staged --redact --config "${config}" . else - gitleaks detect --source . --redact --config "${config}" + gitleaks git --redact --config "${config}" . fi } @@ -70,8 +70,8 @@ is_sensitive_path() { check_content_file() { local path="$1" local file="$2" - local base64_secret_regex=$'\\b(KEYS_AVB_BASE64|KEYS_CERT_OTA_BASE64|KEYS_OTA_BASE64|AVB_KEY|CERT_OTA|OTA_KEY)\\b[[:space:]]*[:=][[:space:]]*["\\\']?[A-Za-z0-9+/]{40,}={0,2}' - local passphrase_regex=$'\\b(PASSPHRASE_AVB|PASSPHRASE_OTA)\\b[[:space:]]*[:=][[:space:]]*["\\\']?[^$[:space:]"\\\'][^[:space:]"\\\']{7,}' + local base64_secret_regex=$'\\b(KEYS_AVB_BASE64|KEYS_CERT_OTA_BASE64|KEYS_OTA_BASE64|AVB_KEY|CERT_OTA|OTA_KEY)\\b[[:blank:]]*[:=][[:blank:]]*["\\\']?[A-Za-z0-9+/]{40,}={0,2}' + local passphrase_regex=$'\\b(PASSPHRASE_AVB|PASSPHRASE_OTA)\\b[[:blank:]]*[:=][[:blank:]]*["\\\']?[^$[:space:]"\\\'][^[:space:]"\\\']{7,}' if grep -IEq -- '-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----' "${file}"; then record_failure "${path}" "contains a private-key PEM marker" diff --git a/tests/secrets_scan_test.sh b/tests/secrets_scan_test.sh new file mode 100755 index 00000000..0d88751e --- /dev/null +++ b/tests/secrets_scan_test.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2024-2026 PixeneOS contributors + +set -euo pipefail + +repo_root="$(git rev-parse --show-toplevel)" +fixture_repo="$(mktemp -d)" +trap 'rm -rf "${fixture_repo}"' EXIT + +cp "${repo_root}/.gitleaks.toml" "${fixture_repo}/.gitleaks.toml" +cp "${repo_root}/src/scan_secrets.sh" "${fixture_repo}/scan_secrets.sh" +chmod +x "${fixture_repo}/scan_secrets.sh" +git -C "${fixture_repo}" init --quiet + +cat >"${fixture_repo}/workflow.yml" <<'EOF' +secrets: + PASSPHRASE_AVB: + required: true + PASSPHRASE_OTA: + required: true +EOF + +( + cd "${fixture_repo}" + PIXENEOS_SKIP_GITLEAKS=true ./scan_secrets.sh --all >/dev/null +) + +if command -v gitleaks >/dev/null 2>&1; then + gitleaks dir \ + --redact \ + --no-banner \ + --log-level error \ + --config "${fixture_repo}/.gitleaks.toml" \ + "${fixture_repo}" +fi + +synthetic_value="synthetic" +synthetic_value+="-passphrase" +printf 'PASSPHRASE_AVB=%s\n' "${synthetic_value}" >"${fixture_repo}/unsafe.env" + +if ( + cd "${fixture_repo}" + PIXENEOS_SKIP_GITLEAKS=true ./scan_secrets.sh --all >/dev/null 2>&1 +); then + echo "fallback scanner accepted a same-line signing passphrase" >&2 + exit 1 +fi + +if command -v gitleaks >/dev/null 2>&1 && + gitleaks dir \ + --redact \ + --no-banner \ + --log-level error \ + --config "${fixture_repo}/.gitleaks.toml" \ + "${fixture_repo}" >/dev/null 2>&1; then + echo "gitleaks accepted a same-line signing passphrase" >&2 + exit 1 +fi + +echo "Secrets scan regression tests passed" From bd7e2bbcabf114b0c41ac87050327956c3de8c59 Mon Sep 17 00:00:00 2001 From: 0cwa Date: Mon, 20 Jul 2026 01:11:03 +0200 Subject: [PATCH 12/18] Lock executable tool trust metadata --- docs/executable-tool-trust.md | 132 ++++++++++ locks/executable-tools-v1.json | 94 +++++++ src/validate_executable_tool_lock.py | 354 +++++++++++++++++++++++++++ tests/executable_tool_lock_test.sh | 349 ++++++++++++++++++++++++++ trust/chenxiaolong.allowed_signers | 1 + 5 files changed, 930 insertions(+) create mode 100644 docs/executable-tool-trust.md create mode 100644 locks/executable-tools-v1.json create mode 100644 src/validate_executable_tool_lock.py create mode 100755 tests/executable_tool_lock_test.sh create mode 100644 trust/chenxiaolong.allowed_signers diff --git a/docs/executable-tool-trust.md b/docs/executable-tool-trust.md new file mode 100644 index 00000000..2085486d --- /dev/null +++ b/docs/executable-tool-trust.md @@ -0,0 +1,132 @@ +# Executable tool trust lock + +`locks/executable-tools-v1.json` records the immutable release archives and +authenticated layouts for the three executable tools currently pinned by +PixeneOS on `x86_64-unknown-linux-gnu`. The lock is repository data only. It is +validated offline by `src/validate_executable_tool_lock.py`. + +## Trust root + +The sole allowed signer is the Ed25519 key published in chenxiaolong's +[SSH signature verification guide][signing-guide]: + +```text +identity: chenxiaolong +namespace: file +key type: ssh-ed25519 +fingerprint: SHA256:Ct0HoRyrFLrnF9W+A/BKEiJmwx7yWkgaW/JvghKrboA +``` + +The exact allowed-signers binding is checked in at +`trust/chenxiaolong.allowed_signers`. The validator parses the OpenSSH key blob, +requires an Ed25519 key with exactly 32 public-key bytes, recomputes the OpenSSH +SHA-256 fingerprint with Python's standard library, and requires every lock +entry to name that fingerprint, identity, and namespace. + +## Recorded release artifacts + +The following archive sizes and SHA-256 digests were independently recomputed +from downloads made on 2026-07-20. They matched the sizes and SHA-256 asset +digests exposed by GitHub's release API. Each detached signature was exactly 294 +bytes and was accepted by `ssh-keygen -Y verify` using identity `chenxiaolong` +and namespace `file` only after the archive digest and size had been checked. + +| Tool | Release archive | Bytes | SHA-256 | +| --- | --- | ---: | --- | +| afsr 1.0.4 | `afsr-1.0.4-x86_64-unknown-linux-gnu.zip` | 1,548,868 | `8fdbc9aa6c31b4e6530388ffc5adc42652ec6bbd753aef0815d27d8c3a4b9687` | +| avbroot 3.31.0 | `avbroot-3.31.0-x86_64-unknown-linux-gnu.zip` | 3,979,155 | `59e7992c2a6379d8ee351e423a851ef360a97cd14a37e6b2e57608eb477c3210` | +| custota-tool 6.2 | `custota-tool-6.2-x86_64-unknown-linux-gnu.zip` | 2,153,916 | `e682c558f8111287b9668f647bc9fded3fa095714957fd8090bc386bae02917d` | + +Detached-signature SHA-256 digests observed during that verification were: + +| Signature | SHA-256 | +| --- | --- | +| `afsr-1.0.4-x86_64-unknown-linux-gnu.zip.sig` | `354bd28d0c1cf20a9ca76dfb958451ad17fa1f34f125fec1ddae58fffc315616` | +| `avbroot-3.31.0-x86_64-unknown-linux-gnu.zip.sig` | `2fb0067d577310b138f542161cc04e0901249c5b661c7f15784822141b8a3437` | +| `custota-tool-6.2-x86_64-unknown-linux-gnu.zip.sig` | `ca28ad6130108240fa67418059426b94e001d905e1a51f24eff4bb692f4570ee` | + +GitHub does not publish separate publisher-signed checksum files for these +releases. GitHub's asset digest is discovery metadata, not a replacement for the +publisher's detached SSH signature. Builds must consume the reviewed digest in +the checked-in lock and must not obtain mutable digest policy from the API. + +Official releases: + +- [afsr v1.0.4][afsr-release] +- [avbroot v3.31.0][avbroot-release] +- [Custota v6.2][custota-release] + +## Verification and archive inspection + +The release archives were downloaded to a private temporary directory. Before +any extraction, each archive was checked for its exact byte size and SHA-256, +verified with the detached OpenSSH signature, and inspected through Python's ZIP +central-directory parser. Inspection rejected duplicate names, absolute paths, +backslashes, empty or dot components, non-normalized paths, encrypted members, +and non-file/non-directory Unix entry types. The reviewed archives contained: + +| Tool | Member | Type | Mode | Bytes | SHA-256 | +| --- | --- | --- | --- | ---: | --- | +| afsr | `afsr` | file | `0755` | 3,469,744 | `923fa7caaac8b5e3b15b3f0f2e9a08ca34b226cbbcee3f80f40ee5afc735c6d7` | +| avbroot | `LICENSE` | file | `0644` | 35,149 | `3972dc9744f6499f0f9b2dbf76696f2ae7ad8af9b23dde66d6af86c9dfb36986` | +| avbroot | `README.md` | file | `0644` | 31,744 | `29c520ac9a61f71cd2db30e091ef638a523dc2e87a16f5340763396a20c3c97e` | +| avbroot | `avbroot` | file | `0755` | 10,139,408 | `7fdfa4a6c8a3145c846faeea1aa49aa407c296710d72744a88e8a8c441e05ea0` | +| custota-tool | `custota-tool` | file | `0755` | 5,056,240 | `3245641f1f7cfef3b6fd0257d6bdda83fa9c65c533e289e4c8f12e1e8c50b41a` | + +Only after those checks passed were the archives extracted into separate private +temporary directories. The extracted member types, modes, sizes, and SHA-256 +digests matched the central-directory inspection and the committed lock. + +The verification environment was: + +- curl 8.18.0 with OpenSSL 3.5.5 +- OpenSSH 10.2p1 with OpenSSL 3.5.5 +- Python 3.14.5 standard-library `zipfile` +- GNU coreutils `sha256sum` 9.10 +- Info-ZIP `unzip` 6.00, used only after authentication and inspection + +## Offline validation policy + +Run: + +```sh +python3 src/validate_executable_tool_lock.py +``` + +For fixtures or independent copies, use `--lock PATH --trust PATH`. Validation +does not access the network. It fails closed on duplicate or unknown JSON fields, +duplicate tools or members, noncanonical JSON, uppercase or malformed hashes, +unbounded sizes, unsafe or unsorted layout paths, unknown members or modes, +unreviewed tool IDs, versions, architectures, URLs, signature parameters, trust +keys, and fingerprints. Both input paths must resolve directly to bounded regular +files; symlinks are not followed. + +The current schema deliberately recognizes only these three releases on +`x86_64-unknown-linux-gnu`. Adding another version, platform, tool, member, or +mode requires a reviewed validator-policy update and regenerated canonical lock. + +## Trust rotation + +Never fetch or replace signing keys automatically. A signer rotation requires an +authoritative publisher statement binding the replacement key, independent +review of that provenance, and a single-purpose change that updates the trust +file, validator binding, and affected lock entries together. Rotation tests must +demonstrate acceptance of the new key and rejection of the retired key, wrong +identities, and wrong namespaces. Re-verify all retained release artifacts after +rotation; do not infer that a new key authenticates old assets without valid new +signatures or an authoritative cross-binding. + +## Explicit non-authorization + +This tranche does **not** integrate the lock into PixeneOS acquisition, cache +reuse, extraction, permission changes, or execution. The presence of a lock +entry, valid repository data, successful fixture validation, or prior manual +inspection does not authorize executing an archive member. Until a later, +independently reviewed integration enforces digest and signature verification on +the actual downloaded inode before any extraction, `chmod`, or execution, the +existing executable-tool acquisition path remains untrusted. + +[signing-guide]: https://github.com/chenxiaolong/chenxiaolong/blob/master/VERIFY_SSH_SIGNATURES.md +[afsr-release]: https://github.com/chenxiaolong/afsr/releases/tag/v1.0.4 +[avbroot-release]: https://github.com/chenxiaolong/avbroot/releases/tag/v3.31.0 +[custota-release]: https://github.com/chenxiaolong/Custota/releases/tag/v6.2 diff --git a/locks/executable-tools-v1.json b/locks/executable-tools-v1.json new file mode 100644 index 00000000..9d5fa5c6 --- /dev/null +++ b/locks/executable-tools-v1.json @@ -0,0 +1,94 @@ +{ + "schema_version": 1, + "tools": [ + { + "arch": "x86_64-unknown-linux-gnu", + "artifact_name": "afsr-1.0.4-x86_64-unknown-linux-gnu.zip", + "id": "afsr", + "layout": [ + { + "mode": "0755", + "path": "afsr", + "sha256": "923fa7caaac8b5e3b15b3f0f2e9a08ca34b226cbbcee3f80f40ee5afc735c6d7", + "size": 3469744, + "type": "file" + } + ], + "sha256": "8fdbc9aa6c31b4e6530388ffc5adc42652ec6bbd753aef0815d27d8c3a4b9687", + "signature": { + "identity": "chenxiaolong", + "namespace": "file", + "signer_fingerprint": "SHA256:Ct0HoRyrFLrnF9W+A/BKEiJmwx7yWkgaW/JvghKrboA", + "type": "ssh", + "url": "https://github.com/chenxiaolong/afsr/releases/download/v1.0.4/afsr-1.0.4-x86_64-unknown-linux-gnu.zip.sig" + }, + "size": 1548868, + "url": "https://github.com/chenxiaolong/afsr/releases/download/v1.0.4/afsr-1.0.4-x86_64-unknown-linux-gnu.zip", + "version": "1.0.4" + }, + { + "arch": "x86_64-unknown-linux-gnu", + "artifact_name": "avbroot-3.31.0-x86_64-unknown-linux-gnu.zip", + "id": "avbroot", + "layout": [ + { + "mode": "0644", + "path": "LICENSE", + "sha256": "3972dc9744f6499f0f9b2dbf76696f2ae7ad8af9b23dde66d6af86c9dfb36986", + "size": 35149, + "type": "file" + }, + { + "mode": "0644", + "path": "README.md", + "sha256": "29c520ac9a61f71cd2db30e091ef638a523dc2e87a16f5340763396a20c3c97e", + "size": 31744, + "type": "file" + }, + { + "mode": "0755", + "path": "avbroot", + "sha256": "7fdfa4a6c8a3145c846faeea1aa49aa407c296710d72744a88e8a8c441e05ea0", + "size": 10139408, + "type": "file" + } + ], + "sha256": "59e7992c2a6379d8ee351e423a851ef360a97cd14a37e6b2e57608eb477c3210", + "signature": { + "identity": "chenxiaolong", + "namespace": "file", + "signer_fingerprint": "SHA256:Ct0HoRyrFLrnF9W+A/BKEiJmwx7yWkgaW/JvghKrboA", + "type": "ssh", + "url": "https://github.com/chenxiaolong/avbroot/releases/download/v3.31.0/avbroot-3.31.0-x86_64-unknown-linux-gnu.zip.sig" + }, + "size": 3979155, + "url": "https://github.com/chenxiaolong/avbroot/releases/download/v3.31.0/avbroot-3.31.0-x86_64-unknown-linux-gnu.zip", + "version": "3.31.0" + }, + { + "arch": "x86_64-unknown-linux-gnu", + "artifact_name": "custota-tool-6.2-x86_64-unknown-linux-gnu.zip", + "id": "custota-tool", + "layout": [ + { + "mode": "0755", + "path": "custota-tool", + "sha256": "3245641f1f7cfef3b6fd0257d6bdda83fa9c65c533e289e4c8f12e1e8c50b41a", + "size": 5056240, + "type": "file" + } + ], + "sha256": "e682c558f8111287b9668f647bc9fded3fa095714957fd8090bc386bae02917d", + "signature": { + "identity": "chenxiaolong", + "namespace": "file", + "signer_fingerprint": "SHA256:Ct0HoRyrFLrnF9W+A/BKEiJmwx7yWkgaW/JvghKrboA", + "type": "ssh", + "url": "https://github.com/chenxiaolong/Custota/releases/download/v6.2/custota-tool-6.2-x86_64-unknown-linux-gnu.zip.sig" + }, + "size": 2153916, + "url": "https://github.com/chenxiaolong/Custota/releases/download/v6.2/custota-tool-6.2-x86_64-unknown-linux-gnu.zip", + "version": "6.2" + } + ] +} diff --git a/src/validate_executable_tool_lock.py b/src/validate_executable_tool_lock.py new file mode 100644 index 00000000..794780c2 --- /dev/null +++ b/src/validate_executable_tool_lock.py @@ -0,0 +1,354 @@ +#!/usr/bin/env python3 +"""Validate the checked-in executable-tool lock and SSH trust binding offline.""" + +from __future__ import annotations + +import argparse +import base64 +import binascii +import hashlib +import json +import os +from pathlib import Path, PurePosixPath +import re +import stat +import struct +import sys +from typing import Any + + +REPO_ROOT = Path(__file__).resolve().parents[1] +DEFAULT_LOCK = REPO_ROOT / "locks" / "executable-tools-v1.json" +DEFAULT_TRUST = REPO_ROOT / "trust" / "chenxiaolong.allowed_signers" + +SCHEMA_VERSION = 1 +ARCH = "x86_64-unknown-linux-gnu" +SIGNER_IDENTITY = "chenxiaolong" +SIGNATURE_NAMESPACE = "file" +SIGNATURE_TYPE = "ssh" +SIGNER_KEY_TYPE = "ssh-ed25519" +SIGNER_KEY = "AAAAC3NzaC1lZDI1NTE5AAAAIDOe6/tBnO7xZhAWXRj3ApUYgn+XZ0wnQiXM8B7tPgv4" +SIGNER_FINGERPRINT = "SHA256:Ct0HoRyrFLrnF9W+A/BKEiJmwx7yWkgaW/JvghKrboA" + +MAX_LOCK_BYTES = 1024 * 1024 +MAX_TRUST_BYTES = 4096 +MAX_ARCHIVE_BYTES = 64 * 1024 * 1024 +MAX_MEMBER_BYTES = 128 * 1024 * 1024 + +TOP_LEVEL_FIELDS = frozenset(("schema_version", "tools")) +TOOL_FIELDS = frozenset( + ( + "id", + "version", + "arch", + "artifact_name", + "url", + "size", + "sha256", + "signature", + "layout", + ) +) +SIGNATURE_FIELDS = frozenset( + ("url", "type", "identity", "namespace", "signer_fingerprint") +) +LAYOUT_FIELDS = frozenset(("path", "type", "size", "sha256", "mode")) +LOWER_SHA256 = re.compile(r"[0-9a-f]{64}\Z") +MODE = re.compile(r"0[0-7]{3}\Z") + +TOOL_POLICY = { + "afsr": { + "version": "1.0.4", + "repository": "afsr", + "artifact_name": "afsr-1.0.4-x86_64-unknown-linux-gnu.zip", + "layout": {"afsr": "0755"}, + }, + "avbroot": { + "version": "3.31.0", + "repository": "avbroot", + "artifact_name": "avbroot-3.31.0-x86_64-unknown-linux-gnu.zip", + "layout": {"LICENSE": "0644", "README.md": "0644", "avbroot": "0755"}, + }, + "custota-tool": { + "version": "6.2", + "repository": "Custota", + "artifact_name": "custota-tool-6.2-x86_64-unknown-linux-gnu.zip", + "layout": {"custota-tool": "0755"}, + }, +} + + +class ValidationError(Exception): + """Raised when lock or trust data fails closed.""" + + +def fail(message: str) -> None: + raise ValidationError(message) + + +def require_exact_fields(value: Any, expected: frozenset[str], context: str) -> dict[str, Any]: + if type(value) is not dict: + fail(f"{context} must be an object") + actual = frozenset(value) + missing = sorted(expected - actual) + unknown = sorted(actual - expected) + if missing or unknown: + details = [] + if missing: + details.append(f"missing fields: {', '.join(missing)}") + if unknown: + details.append(f"unknown fields: {', '.join(unknown)}") + fail(f"{context} has {'; '.join(details)}") + return value + + +def require_string(value: Any, context: str) -> str: + if type(value) is not str or not value: + fail(f"{context} must be a nonempty string") + return value + + +def require_bounded_size(value: Any, maximum: int, context: str) -> int: + if type(value) is not int or not 0 < value <= maximum: + fail(f"{context} must be an integer between 1 and {maximum}") + return value + + +def require_sha256(value: Any, context: str) -> str: + digest = require_string(value, context) + if LOWER_SHA256.fullmatch(digest) is None: + fail(f"{context} must be exactly 64 lowercase hexadecimal characters") + return digest + + +def reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + fail(f"duplicate JSON object key: {key}") + result[key] = value + return result + + +def reject_nonstandard_number(value: str) -> None: + fail(f"nonstandard JSON number is forbidden: {value}") + + +def read_regular_file(path: Path, maximum: int, context: str) -> bytes: + try: + before = path.lstat() + except OSError as exc: + fail(f"cannot stat {context}: {exc.strerror or exc}") + if not stat.S_ISREG(before.st_mode): + fail(f"{context} must be a regular, non-symlink file") + if before.st_size > maximum: + fail(f"{context} exceeds the {maximum}-byte limit") + if not hasattr(os, "O_NOFOLLOW"): + fail("this platform cannot enforce no-follow reads") + + flags = os.O_RDONLY | os.O_NOFOLLOW + if hasattr(os, "O_CLOEXEC"): + flags |= os.O_CLOEXEC + try: + descriptor = os.open(path, flags) + except OSError as exc: + fail(f"cannot open {context} without following links: {exc.strerror or exc}") + try: + with os.fdopen(descriptor, "rb", closefd=True) as stream: + opened = os.fstat(stream.fileno()) + if not stat.S_ISREG(opened.st_mode): + fail(f"{context} must remain a regular file") + if (opened.st_dev, opened.st_ino) != (before.st_dev, before.st_ino): + fail(f"{context} changed before it was opened") + if opened.st_size != before.st_size or opened.st_size > maximum: + fail(f"{context} changed size before it was opened") + data = stream.read(maximum + 1) + after = os.fstat(stream.fileno()) + except OSError as exc: + fail(f"cannot read {context}: {exc.strerror or exc}") + if len(data) != opened.st_size or opened.st_size != after.st_size: + fail(f"{context} changed while being read") + return data + + +def parse_ssh_string(blob: bytes, offset: int, context: str) -> tuple[bytes, int]: + if len(blob) - offset < 4: + fail(f"{context} has a truncated SSH string length") + length = struct.unpack(">I", blob[offset : offset + 4])[0] + offset += 4 + end = offset + length + if end > len(blob): + fail(f"{context} has a truncated SSH string") + return blob[offset:end], end + + +def validate_trust(path: Path) -> str: + raw = read_regular_file(path, MAX_TRUST_BYTES, "trust file") + expected_line = f"{SIGNER_IDENTITY} {SIGNER_KEY_TYPE} {SIGNER_KEY}\n".encode("ascii") + if raw != expected_line: + fail("trust file must contain exactly the reviewed chenxiaolong allowed-signer binding") + + try: + key_blob = base64.b64decode(SIGNER_KEY, validate=True) + except (binascii.Error, ValueError) as exc: + fail(f"trusted SSH key is not canonical base64: {exc}") + + key_type, offset = parse_ssh_string(key_blob, 0, "trusted SSH key") + public_key, offset = parse_ssh_string(key_blob, offset, "trusted SSH key") + if offset != len(key_blob): + fail("trusted SSH key has trailing data") + if key_type != SIGNER_KEY_TYPE.encode("ascii"): + fail("trusted SSH key blob type does not match its declaration") + if len(public_key) != 32: + fail("trusted Ed25519 public key must contain exactly 32 key bytes") + + encoded_digest = base64.b64encode(hashlib.sha256(key_blob).digest()).decode("ascii").rstrip("=") + fingerprint = f"SHA256:{encoded_digest}" + if fingerprint != SIGNER_FINGERPRINT: + fail("trusted SSH key does not match the reviewed fingerprint") + return fingerprint + + +def canonical_release_url(repository: str, version: str, artifact_name: str) -> str: + return ( + f"https://github.com/chenxiaolong/{repository}/releases/download/" + f"v{version}/{artifact_name}" + ) + + +def validate_layout(value: Any, tool_id: str, policy: dict[str, Any]) -> None: + if type(value) is not list or not value: + fail(f"tool {tool_id} layout must be a nonempty array") + + paths: list[str] = [] + modes: dict[str, str] = {} + for index, raw_entry in enumerate(value): + context = f"tool {tool_id} layout[{index}]" + entry = require_exact_fields(raw_entry, LAYOUT_FIELDS, context) + path = require_string(entry["path"], f"{context}.path") + pure_path = PurePosixPath(path) + if ( + path.startswith("/") + or "\\" in path + or pure_path.is_absolute() + or len(pure_path.parts) != 1 + or any(part in ("", ".", "..") for part in pure_path.parts) + or pure_path.as_posix() != path + ): + fail(f"{context}.path must be one normalized top-level POSIX name") + if path in modes: + fail(f"tool {tool_id} has duplicate layout path: {path}") + if entry["type"] != "file": + fail(f"{context}.type must be file") + require_bounded_size(entry["size"], MAX_MEMBER_BYTES, f"{context}.size") + require_sha256(entry["sha256"], f"{context}.sha256") + mode = require_string(entry["mode"], f"{context}.mode") + if MODE.fullmatch(mode) is None or mode not in ("0644", "0755"): + fail(f"{context}.mode must be canonical 0644 or 0755") + paths.append(path) + modes[path] = mode + + if paths != sorted(paths): + fail(f"tool {tool_id} layout paths must be sorted") + if modes != policy["layout"]: + fail(f"tool {tool_id} layout paths or modes do not match the reviewed release layout") + + +def validate_tool(raw_tool: Any, index: int, fingerprint: str) -> str: + context = f"tools[{index}]" + tool = require_exact_fields(raw_tool, TOOL_FIELDS, context) + tool_id = require_string(tool["id"], f"{context}.id") + policy = TOOL_POLICY.get(tool_id) + if policy is None: + fail(f"unknown executable tool id: {tool_id}") + + if tool["version"] != policy["version"]: + fail(f"tool {tool_id} has an unreviewed version") + if tool["arch"] != ARCH: + fail(f"tool {tool_id} has an unreviewed architecture") + if tool["artifact_name"] != policy["artifact_name"]: + fail(f"tool {tool_id} has a noncanonical artifact name") + + expected_url = canonical_release_url( + policy["repository"], policy["version"], policy["artifact_name"] + ) + if tool["url"] != expected_url: + fail(f"tool {tool_id} has a noncanonical release URL") + require_bounded_size(tool["size"], MAX_ARCHIVE_BYTES, f"tool {tool_id}.size") + require_sha256(tool["sha256"], f"tool {tool_id}.sha256") + + signature = require_exact_fields( + tool["signature"], SIGNATURE_FIELDS, f"tool {tool_id}.signature" + ) + if signature["url"] != f"{expected_url}.sig": + fail(f"tool {tool_id} has a noncanonical signature URL") + if signature["type"] != SIGNATURE_TYPE: + fail(f"tool {tool_id} has an unsupported signature type") + if signature["identity"] != SIGNER_IDENTITY: + fail(f"tool {tool_id} has an unreviewed signer identity") + if signature["namespace"] != SIGNATURE_NAMESPACE: + fail(f"tool {tool_id} has an unreviewed signature namespace") + if signature["signer_fingerprint"] != fingerprint: + fail(f"tool {tool_id} signer fingerprint does not match the trust file") + + validate_layout(tool["layout"], tool_id, policy) + return tool_id + + +def validate_lock(path: Path, fingerprint: str) -> None: + raw = read_regular_file(path, MAX_LOCK_BYTES, "lock file") + try: + text = raw.decode("utf-8") + except UnicodeDecodeError as exc: + fail(f"lock file is not UTF-8: {exc}") + try: + data = json.loads( + text, + object_pairs_hook=reject_duplicate_keys, + parse_constant=reject_nonstandard_number, + ) + except json.JSONDecodeError as exc: + fail(f"lock file is not valid JSON: {exc.msg} at line {exc.lineno} column {exc.colno}") + + document = require_exact_fields(data, TOP_LEVEL_FIELDS, "lock") + if type(document["schema_version"]) is not int or document["schema_version"] != SCHEMA_VERSION: + fail(f"lock schema_version must be {SCHEMA_VERSION}") + tools = document["tools"] + if type(tools) is not list: + fail("lock tools must be an array") + + tool_ids = [validate_tool(tool, index, fingerprint) for index, tool in enumerate(tools)] + if len(tool_ids) != len(set(tool_ids)): + fail("lock contains duplicate tool ids") + expected_ids = sorted(TOOL_POLICY) + if tool_ids != expected_ids: + fail(f"lock tools must contain exactly these ids in order: {', '.join(expected_ids)}") + + canonical = json.dumps(document, ensure_ascii=True, indent=2, sort_keys=True) + "\n" + if text != canonical: + fail("lock file is not in canonical sorted-key JSON form") + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Validate the executable-tool lock and SSH trust binding without network access." + ) + parser.add_argument("--lock", type=Path, default=DEFAULT_LOCK, help="lock JSON path") + parser.add_argument("--trust", type=Path, default=DEFAULT_TRUST, help="allowed-signers path") + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(sys.argv[1:] if argv is None else argv) + try: + fingerprint = validate_trust(args.trust) + validate_lock(args.lock, fingerprint) + except ValidationError as exc: + print(f"executable-tool lock validation failed: {exc}", file=sys.stderr) + return 1 + print("Executable-tool lock and trust binding are valid.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/executable_tool_lock_test.sh b/tests/executable_tool_lock_test.sh new file mode 100755 index 00000000..3c2a2e52 --- /dev/null +++ b/tests/executable_tool_lock_test.sh @@ -0,0 +1,349 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 PixeneOS contributors + +set -euo pipefail + +LOCK="locks/executable-tools-v1.json" +TRUST="trust/chenxiaolong.allowed_signers" +DOC="docs/executable-tool-trust.md" +VALIDATOR="src/validate_executable_tool_lock.py" +TEST_ROOT="$(mktemp -d)" +trap 'rm -rf -- "${TEST_ROOT}"' EXIT + +fail() { + echo "$*" >&2 + exit 1 +} + +for required_path in "${LOCK}" "${TRUST}" "${DOC}" "${VALIDATOR}"; do + [[ -f "${required_path}" && ! -L "${required_path}" ]] || + fail "required executable-tool trust input is missing or not a regular file: ${required_path}" +done + +run_validator() { + local lock_path="${1}" + + env \ + HTTP_PROXY=http://127.0.0.1:9 \ + HTTPS_PROXY=http://127.0.0.1:9 \ + ALL_PROXY=http://127.0.0.1:9 \ + NO_PROXY= \ + python3 "${VALIDATOR}" \ + --lock "${lock_path}" \ + --trust "${TRUST}" +} + +assert_rejected() { + local case_name="${1}" + local mutated="${TEST_ROOT}/${case_name}.json" + + write_mutation "${case_name}" "${mutated}" + if run_validator "${mutated}" >"${TEST_ROOT}/${case_name}.out" 2>&1; then + fail "invalid executable-tool lock unexpectedly validated: ${case_name}" + fi +} + +write_mutation() { + local case_name="${1}" + local output_path="${2}" + + python3 - "${LOCK}" "${output_path}" "${case_name}" <<'PY' +import copy +import json +import pathlib +import sys + +source, output, case = sys.argv[1:] +data = json.loads(pathlib.Path(source).read_text(encoding="utf-8")) +tools = data["tools"] +tool = tools[0] +layout = tool["layout"] + +if case == "unknown_tool": + tool["id"] = "unknown-tool" +elif case == "unknown_version": + tool["version"] = "999.0" +elif case == "unknown_arch": + tool["arch"] = "aarch64-unknown-linux-gnu" +elif case == "unknown_top_level_field": + data["future_policy"] = True +elif case == "unknown_tool_field": + tool["executable_hint"] = "run-me" +elif case == "duplicate_identity": + tools.append(copy.deepcopy(tool)) +elif case == "duplicate_member": + layout.append(copy.deepcopy(layout[0])) +elif case == "wrong_artifact_name": + tool["artifact_name"] = "another-tool.zip" +elif case == "noncanonical_artifact_url": + tool["url"] += "?download=1" +elif case == "floating_artifact_url": + tool["url"] = ( + "https://github.com/chenxiaolong/afsr/" + "releases/latest/download/afsr-latest.zip" + ) +elif case == "mutable_branch_url": + tool["url"] = ( + "https://raw.githubusercontent.com/chenxiaolong/afsr/" + "main/afsr.zip" + ) +elif case == "noncanonical_archive_hash": + tool["sha256"] = tool["sha256"].upper() +elif case == "short_archive_hash": + tool["sha256"] = "0" * 63 +elif case == "signature_url_mismatch": + tool["signature"]["url"] = tool["url"] + ".wrong.sig" +elif case == "floating_signature_url": + tool["signature"]["url"] = ( + "https://github.com/chenxiaolong/afsr/" + "releases/latest/download/afsr-latest.zip.sig" + ) +elif case == "unknown_signature_type": + tool["signature"]["type"] = "openpgp" +elif case == "wrong_signature_identity": + tool["signature"]["identity"] = "unreviewed" +elif case == "wrong_signature_namespace": + tool["signature"]["namespace"] = "git" +elif case == "signer_fingerprint_mismatch": + tool["signature"]["signer_fingerprint"] = ( + "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + ) +elif case == "symlink_layout": + layout[0]["type"] = "symlink" +elif case == "special_layout": + layout[0]["type"] = "device" +elif case == "extra_layout_member": + extra = copy.deepcopy(layout[0]) + extra["path"] = "unexpected-helper" + layout.append(extra) +elif case == "noncanonical_layout_mode": + layout[0]["mode"] = "755" +elif case == "wrong_layout_mode": + layout[0]["mode"] = "0777" +elif case == "noncanonical_executable_hash": + layout[0]["sha256"] = layout[0]["sha256"].upper() +elif case == "short_executable_hash": + layout[0]["sha256"] = "f" * 63 +elif case.startswith("missing_"): + targets = { + "missing_artifact_size": (tool, "size"), + "missing_archive_hash": (tool, "sha256"), + "missing_signature_url": (tool["signature"], "url"), + "missing_signature_type": (tool["signature"], "type"), + "missing_signature_identity": (tool["signature"], "identity"), + "missing_signature_namespace": (tool["signature"], "namespace"), + "missing_signer_fingerprint": ( + tool["signature"], + "signer_fingerprint", + ), + "missing_layout": (tool, "layout"), + "missing_executable_size": (layout[0], "size"), + "missing_executable_hash": (layout[0], "sha256"), + "missing_executable_mode": (layout[0], "mode"), + } + target, key = targets[case] + del target[key] +else: + raise SystemExit(f"unknown mutation case: {case}") + +pathlib.Path(output).write_text( + json.dumps(data, indent=2, sort_keys=True) + "\n", + encoding="utf-8", +) +PY +} + +test_canonical_committed_lock() { + run_validator "${LOCK}" >/dev/null + + python3 - "${LOCK}" "${TRUST}" <<'PY' +import json +import pathlib +import re +import sys + +lock_path, trust_path = map(pathlib.Path, sys.argv[1:]) +data = json.loads(lock_path.read_text(encoding="utf-8")) + +if data.get("schema_version") != 1: + raise SystemExit("schema_version must be 1") +if set(data) != {"schema_version", "tools"}: + raise SystemExit("top-level executable lock fields are not exact") + +expected = { + "afsr": { + "version": "1.0.4", + "artifact": "afsr-1.0.4-x86_64-unknown-linux-gnu.zip", + "layout": {"afsr": "0755"}, + "url": ( + "https://github.com/chenxiaolong/afsr/releases/download/" + "v1.0.4/afsr-1.0.4-x86_64-unknown-linux-gnu.zip" + ), + }, + "avbroot": { + "version": "3.31.0", + "artifact": "avbroot-3.31.0-x86_64-unknown-linux-gnu.zip", + "layout": { + "LICENSE": "0644", + "README.md": "0644", + "avbroot": "0755", + }, + "url": ( + "https://github.com/chenxiaolong/avbroot/releases/download/" + "v3.31.0/avbroot-3.31.0-x86_64-unknown-linux-gnu.zip" + ), + }, + "custota-tool": { + "version": "6.2", + "artifact": "custota-tool-6.2-x86_64-unknown-linux-gnu.zip", + "layout": {"custota-tool": "0755"}, + "url": ( + "https://github.com/chenxiaolong/Custota/releases/download/" + "v6.2/custota-tool-6.2-x86_64-unknown-linux-gnu.zip" + ), + }, +} + +tools = data.get("tools") +if not isinstance(tools, list) or len(tools) != 3: + raise SystemExit("lock must contain exactly three tools") +if [tool.get("id") for tool in tools] != sorted(expected): + raise SystemExit("tools must be in canonical ID order") + +trust_lines = [ + line.strip() + for line in trust_path.read_text(encoding="utf-8").splitlines() + if line.strip() and not line.lstrip().startswith("#") +] +if len(trust_lines) != 1: + raise SystemExit("trust file must contain exactly one active signer") +trust_identity = trust_lines[0].split(maxsplit=1)[0] + +hash_re = re.compile(r"[0-9a-f]{64}") +fingerprint = "SHA256:Ct0HoRyrFLrnF9W+A/BKEiJmwx7yWkgaW/JvghKrboA" +for tool in tools: + tool_id = tool["id"] + pin = expected[tool_id] + if set(tool) != { + "id", "version", "arch", "artifact_name", "url", "size", + "sha256", "signature", "layout", + }: + raise SystemExit(f"{tool_id}: tool fields are not exact") + if tool["version"] != pin["version"]: + raise SystemExit(f"{tool_id}: wrong version") + if tool["arch"] != "x86_64-unknown-linux-gnu": + raise SystemExit(f"{tool_id}: wrong architecture") + if tool["artifact_name"] != pin["artifact"] or tool["url"] != pin["url"]: + raise SystemExit(f"{tool_id}: wrong canonical artifact identity") + if not isinstance(tool["size"], int) or isinstance(tool["size"], bool) or tool["size"] <= 0: + raise SystemExit(f"{tool_id}: archive size must be positive") + if not hash_re.fullmatch(tool["sha256"]): + raise SystemExit(f"{tool_id}: archive SHA-256 is not canonical") + + signature = tool["signature"] + if set(signature) != { + "url", "type", "identity", "namespace", "signer_fingerprint", + }: + raise SystemExit(f"{tool_id}: signature fields are not exact") + if signature["url"] != tool["url"] + ".sig": + raise SystemExit(f"{tool_id}: signature URL is not artifact-bound") + if signature["type"] != "ssh" or signature["namespace"] != "file": + raise SystemExit(f"{tool_id}: wrong signature protocol") + if signature["identity"] != trust_identity: + raise SystemExit(f"{tool_id}: signature identity is not trust-bound") + if signature["signer_fingerprint"] != fingerprint: + raise SystemExit(f"{tool_id}: signer fingerprint is not the reviewed pin") + + layout = tool["layout"] + if not isinstance(layout, list) or not layout: + raise SystemExit(f"{tool_id}: layout must be a nonempty exact allowlist") + if [member.get("path") for member in layout] != sorted( + member.get("path") for member in layout + ): + raise SystemExit(f"{tool_id}: layout is not in canonical path order") + actual_layout = {member.get("path"): member.get("mode") for member in layout} + if actual_layout != pin["layout"]: + raise SystemExit(f"{tool_id}: layout does not match the reviewed release") + for member in layout: + if set(member) != {"path", "type", "size", "sha256", "mode"}: + raise SystemExit(f"{tool_id}: layout fields are not exact") + if member["type"] != "file": + raise SystemExit(f"{tool_id}: layout members must be regular files") + if not isinstance(member["size"], int) or isinstance(member["size"], bool) or member["size"] <= 0: + raise SystemExit(f"{tool_id}: member size must be positive") + if not hash_re.fullmatch(member["sha256"]): + raise SystemExit(f"{tool_id}: member SHA-256 is not canonical") + executable = next(member for member in layout if member["path"] == tool_id) + if executable["mode"] != "0755": + raise SystemExit(f"{tool_id}: executable mode must be canonical 0755") +PY +} + +test_invalid_locks_fail_closed() { + local case_name + local -a cases=( + unknown_tool + unknown_version + unknown_arch + unknown_top_level_field + unknown_tool_field + duplicate_identity + duplicate_member + wrong_artifact_name + noncanonical_artifact_url + floating_artifact_url + mutable_branch_url + noncanonical_archive_hash + short_archive_hash + signature_url_mismatch + floating_signature_url + unknown_signature_type + wrong_signature_identity + wrong_signature_namespace + signer_fingerprint_mismatch + symlink_layout + special_layout + extra_layout_member + noncanonical_layout_mode + wrong_layout_mode + noncanonical_executable_hash + short_executable_hash + missing_artifact_size + missing_archive_hash + missing_signature_url + missing_signature_type + missing_signature_identity + missing_signature_namespace + missing_signer_fingerprint + missing_layout + missing_executable_size + missing_executable_hash + missing_executable_mode + ) + + for case_name in "${cases[@]}"; do + assert_rejected "${case_name}" + done +} + +test_trust_documentation_contract() { + grep -Eqi 'provenance|authoritative source' "${DOC}" || + fail "trust documentation does not record signer provenance" + grep -Eqi 'fingerprint|SHA256:' "${DOC}" || + fail "trust documentation does not record the reviewed signer fingerprint" + grep -Eqi 'rotat(e|ion)|replacement key' "${DOC}" || + fail "trust documentation does not define signer rotation" + grep -Eqi 'review|approval' "${DOC}" || + fail "trust documentation does not require review for rotation" + grep -Eqi \ + '(does not|never|no).*(authoriz(e|ation)|permit).*(extract|execut|install)|verification.*(does not|never).*(extract|execut|install)' \ + "${DOC}" || + fail "trust documentation does not state that verification grants no execution authorization" +} + +test_canonical_committed_lock +test_invalid_locks_fail_closed +test_trust_documentation_contract + +echo "executable tool lock tests passed" diff --git a/trust/chenxiaolong.allowed_signers b/trust/chenxiaolong.allowed_signers new file mode 100644 index 00000000..1731d413 --- /dev/null +++ b/trust/chenxiaolong.allowed_signers @@ -0,0 +1 @@ +chenxiaolong ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDOe6/tBnO7xZhAWXRj3ApUYgn+XZ0wnQiXM8B7tPgv4 From 126cf4a210372051c7feff093321ad8c27c20367 Mon Sep 17 00:00:00 2001 From: 0cwa Date: Mon, 20 Jul 2026 01:23:17 +0200 Subject: [PATCH 13/18] Ignore reviewed public signer key finding --- .gitleaksignore | 2 ++ src/validate_executable_tool_lock.py | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.gitleaksignore b/.gitleaksignore index b44fe686..341d1d92 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -3,3 +3,5 @@ # path, rule, and line so later findings remain fail-closed. 97c0d147a50d0cf5e2714089cd35acd0e205d578:src/util_functions.sh:generic-api-key:30 97c0d147a50d0cf5e2714089cd35acd0e205d578:src/util_functions.sh:generic-api-key:40 +# Reviewed public Ed25519 signer key constant; not secret material. +bd7e2bbcabf114b0c41ac87050327956c3de8c59:src/validate_executable_tool_lock.py:generic-api-key:30 diff --git a/src/validate_executable_tool_lock.py b/src/validate_executable_tool_lock.py index 794780c2..bb0acf21 100644 --- a/src/validate_executable_tool_lock.py +++ b/src/validate_executable_tool_lock.py @@ -27,7 +27,7 @@ SIGNATURE_NAMESPACE = "file" SIGNATURE_TYPE = "ssh" SIGNER_KEY_TYPE = "ssh-ed25519" -SIGNER_KEY = "AAAAC3NzaC1lZDI1NTE5AAAAIDOe6/tBnO7xZhAWXRj3ApUYgn+XZ0wnQiXM8B7tPgv4" +SIGNER_PUBLIC_KEY = "AAAAC3NzaC1lZDI1NTE5AAAAIDOe6/tBnO7xZhAWXRj3ApUYgn+XZ0wnQiXM8B7tPgv4" SIGNER_FINGERPRINT = "SHA256:Ct0HoRyrFLrnF9W+A/BKEiJmwx7yWkgaW/JvghKrboA" MAX_LOCK_BYTES = 1024 * 1024 @@ -184,12 +184,12 @@ def parse_ssh_string(blob: bytes, offset: int, context: str) -> tuple[bytes, int def validate_trust(path: Path) -> str: raw = read_regular_file(path, MAX_TRUST_BYTES, "trust file") - expected_line = f"{SIGNER_IDENTITY} {SIGNER_KEY_TYPE} {SIGNER_KEY}\n".encode("ascii") + expected_line = f"{SIGNER_IDENTITY} {SIGNER_KEY_TYPE} {SIGNER_PUBLIC_KEY}\n".encode("ascii") if raw != expected_line: fail("trust file must contain exactly the reviewed chenxiaolong allowed-signer binding") try: - key_blob = base64.b64decode(SIGNER_KEY, validate=True) + key_blob = base64.b64decode(SIGNER_PUBLIC_KEY, validate=True) except (binascii.Error, ValueError) as exc: fail(f"trusted SSH key is not canonical base64: {exc}") From f624767340312e1c16660f31bead480998c4dae8 Mon Sep 17 00:00:00 2001 From: 0cwa Date: Mon, 20 Jul 2026 14:57:02 +0200 Subject: [PATCH 14/18] feat(bootstrap): authenticate and execute locked tools Verify locked artifacts with exact hash, size, and OpenSSH signatures before hostile-archive extraction. Revalidate cache and installs, publish atomically, and execute direct tool calls from post-verified sealed file descriptors. Co-Authored-By: ruflo-bot --- src/bootstrap_archive.py | 438 +++++++++++++++++++++ src/bootstrap_executable_tools.py | 452 ++++++++++++++++++++++ src/bootstrap_io.py | 208 ++++++++++ src/validate_executable_tool_lock.py | 65 +++- tests/executable_tool_bootstrap_test.py | 482 ++++++++++++++++++++++++ tests/executable_tool_bootstrap_test.sh | 10 + tests/test_bootstrap_archive.py | 436 +++++++++++++++++++++ 7 files changed, 2076 insertions(+), 15 deletions(-) create mode 100644 src/bootstrap_archive.py create mode 100644 src/bootstrap_executable_tools.py create mode 100644 src/bootstrap_io.py create mode 100644 tests/executable_tool_bootstrap_test.py create mode 100644 tests/executable_tool_bootstrap_test.sh create mode 100644 tests/test_bootstrap_archive.py diff --git a/src/bootstrap_archive.py b/src/bootstrap_archive.py new file mode 100644 index 00000000..9d7af1f5 --- /dev/null +++ b/src/bootstrap_archive.py @@ -0,0 +1,438 @@ +#!/usr/bin/env python3 +"""Hostile-ZIP validation and exact extraction for executable bootstraps.""" + +from __future__ import annotations + +from contextlib import contextmanager +import fcntl +import hashlib +import os +from pathlib import Path, PurePosixPath +import posixpath +import stat +import struct +from typing import Any, BinaryIO, Iterator +import zipfile + + +READ_CHUNK = 1024 * 1024 +MAX_COMPRESSION_RATIO = 200 +SUPPORTED_COMPRESSION = frozenset((zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED)) +COMMON_FLAGS = 0x0008 | 0x0800 +DEFLATE_FLAGS = COMMON_FLAGS | 0x0006 + + +class BootstrapError(Exception): + """Raised when executable bootstrap data fails closed.""" + + +def _fail(message: str) -> None: + raise BootstrapError(message) + + +@contextmanager +def open_regular(path: Path, maximum: int, context: str) -> Iterator[BinaryIO]: + """Open one bounded regular inode without following its final link.""" + if not hasattr(os, "O_NOFOLLOW"): + _fail("no-follow file access is unavailable") + try: + before = path.lstat() + except OSError as exc: + _fail(f"cannot stat {context}: {exc.strerror or exc}") + if not stat.S_ISREG(before.st_mode) or before.st_size > maximum: + _fail(f"{context} is not a bounded regular file") + + flags = os.O_RDONLY | os.O_NOFOLLOW + if hasattr(os, "O_CLOEXEC"): + flags |= os.O_CLOEXEC + try: + descriptor = os.open(path, flags) + except OSError as exc: + _fail(f"cannot safely open {context}: {exc.strerror or exc}") + stream = os.fdopen(descriptor, "rb", closefd=True) + try: + opened = os.fstat(stream.fileno()) + if not stat.S_ISREG(opened.st_mode): + _fail(f"{context} changed type") + if (opened.st_dev, opened.st_ino) != (before.st_dev, before.st_ino): + _fail(f"{context} changed before open") + if opened.st_size != before.st_size or opened.st_size > maximum: + _fail(f"{context} changed size before open") + yield stream + after = os.fstat(stream.fileno()) + if (after.st_dev, after.st_ino, after.st_size) != ( + opened.st_dev, + opened.st_ino, + opened.st_size, + ): + _fail(f"{context} changed while open") + finally: + stream.close() + + +def hash_open_file(stream: BinaryIO, maximum: int) -> tuple[int, str]: + stream.seek(0) + digest = hashlib.sha256() + size = 0 + while True: + chunk = stream.read(READ_CHUNK) + if not chunk: + break + size += len(chunk) + if size > maximum: + _fail("file exceeds its byte limit") + digest.update(chunk) + stream.seek(0) + return size, digest.hexdigest() + + +def verify_artifact(stream: BinaryIO, expected_size: int, expected_sha256: str) -> None: + size, digest = hash_open_file(stream, expected_size) + if size != expected_size or digest != expected_sha256: + _fail("archive size or SHA-256 mismatch") + + +def _normalized_member(info: zipfile.ZipInfo) -> str: + name = info.orig_filename + if not name or "\x00" in name or "\\" in name: + _fail("archive member has an unsafe name") + pure = PurePosixPath(name) + normalized = posixpath.normpath(name) + if ( + name.startswith("/") + or pure.is_absolute() + or normalized != name + or any(part in ("", ".", "..") for part in pure.parts) + or len(pure.parts) != 1 + ): + _fail("archive member is not one normalized top-level POSIX name") + return normalized + + +def _member_mode(info: zipfile.ZipInfo) -> int: + if info.create_system != 3: + _fail("archive member lacks authenticated Unix file metadata") + mode = (info.external_attr >> 16) & 0xFFFF + if not stat.S_ISREG(mode): + _fail("archive contains a link, directory, or special file") + return stat.S_IMODE(mode) + + +def _compressed_range( + stream: BinaryIO, info: zipfile.ZipInfo, start_dir: int +) -> tuple[int, int]: + stream.seek(info.header_offset) + header = stream.read(30) + if len(header) != 30 or header[:4] != b"PK\x03\x04": + _fail("archive has an invalid local member header") + local_flags, local_compression = struct.unpack(" start_dir: + _fail("archive member data is outside the valid ZIP data range") + return start, end + + +def inspect_archive(stream: BinaryIO, layout: list[dict[str, Any]]) -> None: + """Authenticate the full ZIP structure and every uncompressed member byte.""" + expected = {entry["path"]: entry for entry in layout} + seen: set[str] = set() + stream.seek(0) + try: + with zipfile.ZipFile(stream) as archive: + infos = archive.infolist() + if len(infos) != len(expected): + _fail("archive member count does not match the exact layout") + ranges: list[tuple[int, int]] = [] + for info in infos: + name = _normalized_member(info) + if name in seen: + _fail("archive has duplicate normalized member names") + seen.add(name) + entry = expected.get(name) + if entry is None: + _fail("archive contains a member outside the exact layout") + if info.flag_bits & 0x1: + _fail("encrypted archive members are forbidden") + if info.compress_type not in SUPPORTED_COMPRESSION: + _fail("archive uses unsupported compression") + allowed_flags = ( + DEFLATE_FLAGS + if info.compress_type == zipfile.ZIP_DEFLATED + else COMMON_FLAGS + ) + if info.flag_bits & ~allowed_flags: + _fail("archive member uses unsupported ZIP flags") + ranges.append(_compressed_range(stream, info, archive.start_dir)) + if _member_mode(info) != int(entry["mode"], 8): + _fail("archive member mode does not match the lock") + if info.file_size != entry["size"]: + _fail("archive member size does not match the lock") + if info.file_size and ( + info.compress_size == 0 + or info.file_size > info.compress_size * MAX_COMPRESSION_RATIO + ): + _fail("archive member exceeds the compression-ratio limit") + + digest = hashlib.sha256() + count = 0 + with archive.open(info, "r") as source: + while True: + chunk = source.read(READ_CHUNK) + if not chunk: + break + count += len(chunk) + if count > entry["size"]: + _fail("archive member expands beyond its locked size") + digest.update(chunk) + if count != entry["size"] or digest.hexdigest() != entry["sha256"]: + _fail("archive member bytes do not match the lock") + ordered_ranges = sorted(ranges) + for previous, current in zip(ordered_ranges, ordered_ranges[1:]): + if current[0] < previous[1]: + _fail("archive member compressed ranges overlap") + except (OSError, zipfile.BadZipFile, RuntimeError, NotImplementedError) as exc: + _fail(f"invalid executable archive: {type(exc).__name__}") + finally: + stream.seek(0) + if seen != set(expected): + _fail("archive is missing locked members") + + +def extract_archive( + stream: BinaryIO, destination: Path, layout: list[dict[str, Any]] +) -> None: + """Extract an already inspected archive into a new private directory.""" + expected = {entry["path"]: entry for entry in layout} + stream.seek(0) + before = destination.lstat() + if not stat.S_ISDIR(before.st_mode): + _fail("extraction destination is not a real directory") + directory_fd = os.open(destination, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + try: + opened = os.fstat(directory_fd) + if (opened.st_dev, opened.st_ino) != (before.st_dev, before.st_ino): + _fail("extraction destination changed before open") + with zipfile.ZipFile(stream) as archive: + for info in archive.infolist(): + name = _normalized_member(info) + entry = expected[name] + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW + descriptor = os.open(name, flags, 0o600, dir_fd=directory_fd) + try: + digest = hashlib.sha256() + count = 0 + with os.fdopen(descriptor, "wb", closefd=False) as target: + with archive.open(info, "r") as source: + while True: + chunk = source.read(READ_CHUNK) + if not chunk: + break + count += len(chunk) + if count > entry["size"]: + _fail("member changed during extraction") + target.write(chunk) + digest.update(chunk) + target.flush() + os.fsync(target.fileno()) + if count != entry["size"] or digest.hexdigest() != entry["sha256"]: + _fail("extracted member bytes do not match the lock") + os.fchmod(descriptor, int(entry["mode"], 8)) + os.fsync(descriptor) + finally: + os.close(descriptor) + os.fsync(directory_fd) + after = os.fstat(directory_fd) + if (after.st_dev, after.st_ino) != (opened.st_dev, opened.st_ino): + _fail("extraction destination changed while open") + finally: + os.close(directory_fd) + stream.seek(0) + + +def _stable_identity(info: os.stat_result) -> tuple[int, ...]: + return ( + info.st_dev, + info.st_ino, + info.st_mode, + info.st_size, + info.st_mtime_ns, + info.st_ctime_ns, + ) + + +def _validate_open_member(directory_fd: int, name: str, entry: dict[str, Any]) -> int: + try: + before = os.stat(name, dir_fd=directory_fd, follow_symlinks=False) + except OSError: + _fail("cannot stat installed executable member") + if not stat.S_ISREG(before.st_mode): + _fail("installed executable member is not a regular file") + if stat.S_IMODE(before.st_mode) != int(entry["mode"], 8): + _fail("installed executable member has the wrong mode") + + flags = os.O_RDONLY | os.O_NOFOLLOW + if hasattr(os, "O_CLOEXEC"): + flags |= os.O_CLOEXEC + try: + descriptor = os.open(name, flags, dir_fd=directory_fd) + except OSError: + _fail("cannot safely open installed executable member") + try: + opened = os.fstat(descriptor) + if _stable_identity(opened) != _stable_identity(before): + _fail("installed executable member changed before open") + with os.fdopen(descriptor, "rb", closefd=False) as stream: + size, digest = hash_open_file(stream, entry["size"]) + after = os.fstat(descriptor) + if _stable_identity(after) != _stable_identity(opened): + _fail("installed executable member changed while being read") + if size != entry["size"] or digest != entry["sha256"]: + _fail("installed executable member does not match the lock") + return descriptor + except Exception: + os.close(descriptor) + raise + + +@contextmanager +def _open_validated_install( + directory: Path, + layout: list[dict[str, Any]], + executable_name: str | None, +) -> Iterator[int | None]: + try: + directory_stat = directory.lstat() + except OSError as exc: + _fail(f"cannot stat installed executable directory: {exc.strerror or exc}") + if not stat.S_ISDIR(directory_stat.st_mode): + _fail("installed executable path is not a real directory") + + directory_fd = os.open(directory, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + executable_fd: int | None = None + try: + opened = os.fstat(directory_fd) + if (opened.st_dev, opened.st_ino) != ( + directory_stat.st_dev, + directory_stat.st_ino, + ): + _fail("installed executable directory changed before open") + expected = {entry["path"]: entry for entry in layout} + if set(os.listdir(directory_fd)) != set(expected): + _fail("installed executable directory does not match the exact layout") + for name, entry in expected.items(): + descriptor = _validate_open_member(directory_fd, name, entry) + if name == executable_name: + executable_fd = descriptor + else: + os.close(descriptor) + if executable_name is not None and executable_fd is None: + _fail("locked executable member is absent from the install layout") + after = os.fstat(directory_fd) + if _stable_identity(after) != _stable_identity(opened): + _fail("installed executable directory changed during validation") + yield executable_fd + finally: + if executable_fd is not None: + os.close(executable_fd) + os.close(directory_fd) + + +@contextmanager +def open_validated_executable( + directory: Path, layout: list[dict[str, Any]], executable_name: str +) -> Iterator[int]: + """Yield the exact locked executable inode while its directory FD is held.""" + entries = {entry["path"]: entry for entry in layout} + executable_entry = entries.get(executable_name) + if executable_entry is None: + _fail("locked executable member is absent from the install layout") + if int(executable_entry["mode"], 8) & 0o111 == 0: + _fail("locked executable member is not executable") + with _open_validated_install(directory, layout, executable_name) as descriptor: + if descriptor is None: + _fail("locked executable member descriptor is unavailable") + yield descriptor + + +@contextmanager +def open_sealed_executable(source_fd: int, entry: dict[str, Any]) -> Iterator[int]: + """Copy locked bytes into a write-sealed anonymous executable inode.""" + required_os = ("memfd_create", "MFD_ALLOW_SEALING", "MFD_CLOEXEC") + required_fcntl = ( + "F_ADD_SEALS", + "F_GET_SEALS", + "F_SEAL_WRITE", + "F_SEAL_GROW", + "F_SEAL_SHRINK", + "F_SEAL_SEAL", + ) + if any(not hasattr(os, name) for name in required_os) or any( + not hasattr(fcntl, name) for name in required_fcntl + ): + _fail("sealed file-descriptor execution is unavailable") + + flags = os.MFD_ALLOW_SEALING | os.MFD_CLOEXEC + try: + descriptor = os.memfd_create("pixene-executable", flags) + except OSError: + _fail("cannot create a sealed executable") + try: + os.lseek(source_fd, 0, os.SEEK_SET) + digest = hashlib.sha256() + count = 0 + while True: + chunk = os.read(source_fd, READ_CHUNK) + if not chunk: + break + count += len(chunk) + if count > entry["size"]: + _fail("executable copy exceeds its locked size") + digest.update(chunk) + view = memoryview(chunk) + while view: + written = os.write(descriptor, view) + if written <= 0: + _fail("short sealed executable write") + view = view[written:] + if count != entry["size"] or digest.hexdigest() != entry["sha256"]: + _fail("sealed executable source does not match the lock") + + os.fchmod(descriptor, int(entry["mode"], 8)) + os.fsync(descriptor) + required_seals = ( + fcntl.F_SEAL_WRITE + | fcntl.F_SEAL_GROW + | fcntl.F_SEAL_SHRINK + | fcntl.F_SEAL_SEAL + ) + optional_exec_seal = getattr(fcntl, "F_SEAL_EXEC", 0) + requested_seals = required_seals | optional_exec_seal + fcntl.fcntl(descriptor, fcntl.F_ADD_SEALS, requested_seals) + applied_seals = fcntl.fcntl(descriptor, fcntl.F_GET_SEALS) + if applied_seals & requested_seals != requested_seals: + _fail("sealed executable is missing required seals") + + with os.fdopen(descriptor, "rb", closefd=False) as stream: + sealed_size, sealed_digest = hash_open_file(stream, entry["size"]) + sealed_stat = os.fstat(descriptor) + if ( + sealed_size != entry["size"] + or sealed_digest != entry["sha256"] + or not stat.S_ISREG(sealed_stat.st_mode) + or stat.S_IMODE(sealed_stat.st_mode) != int(entry["mode"], 8) + ): + _fail("sealed executable does not match the locked bytes and mode") + yield descriptor + except OSError: + _fail("sealed executable preparation failed") + finally: + os.close(descriptor) + + +def validate_install(directory: Path, layout: list[dict[str, Any]]) -> None: + with _open_validated_install(directory, layout, None): + pass diff --git a/src/bootstrap_executable_tools.py b/src/bootstrap_executable_tools.py new file mode 100644 index 00000000..218112ce --- /dev/null +++ b/src/bootstrap_executable_tools.py @@ -0,0 +1,452 @@ +#!/usr/bin/env python3 +"""Acquire and publish authenticated executable tools from the immutable lock.""" + +from __future__ import annotations + +import argparse +from collections.abc import Mapping +from contextlib import ExitStack +import json +import os +from pathlib import Path +import re +import secrets +import shutil +import sys +import tempfile +from typing import Any, BinaryIO, Callable + +from bootstrap_archive import ( + BootstrapError, + extract_archive, + hash_open_file, + inspect_archive, + open_regular, + open_sealed_executable, + open_validated_executable, + validate_install, + verify_artifact, +) +from bootstrap_io import ( + DOWNLOAD_CHUNK, + MAX_SIGNATURE_BYTES, + atomic_write, + committed_bytes, + download_https, + ensure_private_directory, + fsync_directory, + verify_ssh_signature, + write_exclusive, +) +from validate_executable_tool_lock import ( + DEFAULT_LOCK, + DEFAULT_TRUST, + ValidationError, + load_validated_trust, + validate_lock, +) + + +MAX_RECEIPT_BYTES = 4096 +REPORT_SCHEMA = 1 +LOWER_SHA256 = re.compile(r"[0-9a-f]{64}\Z") +UNSAFE_ENVIRONMENT_NAMES = frozenset( + ("BASH_ENV", "ENV", "GLIBC_TUNABLES", "RUST_BACKTRACE", "RUST_LOG") +) +UNSAFE_ENVIRONMENT_PREFIXES = ("LD_", "DYLD_") + + +def fail(message: str) -> None: + raise BootstrapError(message) + + +def fd_exec_supported() -> bool: + return os.execve in os.supports_fd + + +def sanitized_environment(source: Mapping[str, str]) -> dict[str, str]: + result: dict[str, str] = {} + for name, value in source.items(): + if not isinstance(name, str) or not isinstance(value, str): + fail("executable environment must contain only text") + if name in UNSAFE_ENVIRONMENT_NAMES or name.startswith( + UNSAFE_ENVIRONMENT_PREFIXES + ): + continue + if not name or "=" in name or "\x00" in name or "\x00" in value: + fail("executable environment contains an unsafe entry") + result[name] = value + return result + + +class Bootstrapper: + def __init__( + self, + document: dict[str, Any], + trust_bytes: bytes, + workdir: Path, + verifier: Callable[ + [BinaryIO, BinaryIO, BinaryIO, dict[str, Any]], None + ] = verify_ssh_signature, + downloader: Callable[[str, Path, int], tuple[int, str]] = download_https, + ) -> None: + self.tools = {tool["id"]: tool for tool in document["tools"]} + self.trust_bytes = trust_bytes + self.workdir = workdir.absolute() + self.cache = self.workdir / "bootstrap-cache" + self.store = self.workdir / "tools" / "by-sha256" + self.verifier = verifier + self.downloader = downloader + + def _object_path(self, kind: str, digest: str) -> Path: + return self.cache / "objects" / kind / digest + + def _install_path(self, tool: dict[str, Any]) -> Path: + return self.store / tool["sha256"] + + def _publish_object( + self, source: Path, kind: str, digest: str, maximum: int + ) -> Path: + target = self._object_path(kind, digest) + ensure_private_directory(target.parent) + temporary = target.parent / f".new-{secrets.token_hex(16)}" + try: + with open_regular( + source, maximum, "verified bootstrap object" + ) as input_stream: + size, actual = hash_open_file(input_stream, maximum) + if actual != digest: + fail("verified bootstrap object changed before publication") + descriptor = os.open( + temporary, + os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, + 0o600, + ) + try: + input_stream.seek(0) + copied = 0 + while True: + chunk = input_stream.read(DOWNLOAD_CHUNK) + if not chunk: + break + copied += len(chunk) + view = memoryview(chunk) + while view: + written = os.write(descriptor, view) + if written <= 0: + fail("short content-addressed object write") + view = view[written:] + if copied != size: + fail("verified bootstrap object changed during publication") + os.fsync(descriptor) + finally: + os.close(descriptor) + os.link(temporary, target, follow_symlinks=False) + fsync_directory(target.parent) + except FileExistsError: + with open_regular(target, maximum, "cached bootstrap object") as stream: + size, actual = hash_open_file(stream, maximum) + if actual != digest or size > maximum: + fail("content-addressed cache collision or corruption") + finally: + try: + temporary.unlink() + except FileNotFoundError: + pass + return target + + def _receipt(self, tool: dict[str, Any]) -> dict[str, str] | None: + path = self.cache / "receipts" / f"{tool['sha256']}.json" + if path.is_symlink(): + fail("bootstrap cache receipt must not be a symlink") + if not path.exists(): + return None + with open_regular(path, MAX_RECEIPT_BYTES, "bootstrap cache receipt") as stream: + raw = stream.read(MAX_RECEIPT_BYTES + 1) + try: + receipt = json.loads(raw) + except (UnicodeDecodeError, json.JSONDecodeError): + fail("bootstrap cache receipt is invalid") + expected_fields = {"archive_sha256", "signature_sha256", "tool"} + if type(receipt) is not dict or set(receipt) != expected_fields: + fail("bootstrap cache receipt has invalid fields") + if receipt["archive_sha256"] != tool["sha256"] or receipt["tool"] != tool["id"]: + fail("bootstrap cache receipt does not match the lock") + signature_digest = receipt["signature_sha256"] + if ( + not isinstance(signature_digest, str) + or LOWER_SHA256.fullmatch(signature_digest) is None + ): + fail("bootstrap cache receipt has an invalid signature digest") + canonical = json.dumps(receipt, indent=2, sort_keys=True).encode() + b"\n" + if raw != canonical: + fail("bootstrap cache receipt is not canonical") + return receipt + + def _verify_pair( + self, + tool: dict[str, Any], + archive_path: Path, + signature_path: Path, + trust_path: Path, + ) -> None: + with ExitStack() as stack: + archive = stack.enter_context( + open_regular(archive_path, tool["size"], "executable archive") + ) + signature = stack.enter_context( + open_regular(signature_path, MAX_SIGNATURE_BYTES, "OpenSSH signature") + ) + trust = stack.enter_context( + open_regular(trust_path, len(self.trust_bytes), "allowed signers") + ) + verify_artifact(archive, tool["size"], tool["sha256"]) + self.verifier(archive, signature, trust, tool) + inspect_archive(archive, tool["layout"]) + + def _ensure_artifact( + self, tool: dict[str, Any], stage: Path, trust_path: Path + ) -> tuple[Path, Path, str]: + receipt = self._receipt(tool) + if receipt is not None: + archive_path = self._object_path("archives", receipt["archive_sha256"]) + signature_path = self._object_path( + "signatures", receipt["signature_sha256"] + ) + self._verify_pair(tool, archive_path, signature_path, trust_path) + return archive_path, signature_path, receipt["signature_sha256"] + + archive_stage = stage / f"{tool['id']}.archive" + signature_stage = stage / f"{tool['id']}.signature" + size, digest = self.downloader(tool["url"], archive_stage, tool["size"]) + if size != tool["size"] or digest != tool["sha256"]: + fail("downloaded archive size or SHA-256 mismatch") + _, signature_digest = self.downloader( + tool["signature"]["url"], signature_stage, MAX_SIGNATURE_BYTES + ) + self._verify_pair(tool, archive_stage, signature_stage, trust_path) + archive_path = self._publish_object( + archive_stage, "archives", tool["sha256"], tool["size"] + ) + signature_path = self._publish_object( + signature_stage, "signatures", signature_digest, MAX_SIGNATURE_BYTES + ) + receipt_data = { + "archive_sha256": tool["sha256"], + "signature_sha256": signature_digest, + "tool": tool["id"], + } + receipt_path = self.cache / "receipts" / f"{tool['sha256']}.json" + atomic_write( + receipt_path, + json.dumps(receipt_data, indent=2, sort_keys=True).encode() + b"\n", + ) + return archive_path, signature_path, signature_digest + + def _check_legacy_directory(self, tool: dict[str, Any]) -> None: + legacy = self.workdir / "tools" / tool["id"] + if legacy.exists() or legacy.is_symlink(): + validate_install(legacy, tool["layout"]) + fail("legacy executable directory is not digest-bound") + + def install(self, selected: list[str], report_path: Path) -> None: + if not selected or len(selected) != len(set(selected)): + fail("selected executable tool IDs must be unique and nonempty") + try: + tools = [self.tools[tool_id] for tool_id in sorted(selected)] + except KeyError: + fail("selected executable tool is absent from the lock") + ensure_private_directory(self.workdir) + ensure_private_directory(self.cache) + ensure_private_directory(self.store) + try: + report_destination = report_path.resolve(strict=False) + report_destination.relative_to(self.workdir.resolve(strict=True)) + except ValueError: + fail("bootstrap report must remain inside the work directory") + stage_root = self.cache / "staging" + ensure_private_directory(stage_root) + stage = Path(tempfile.mkdtemp(prefix="txn-", dir=stage_root)) + os.chmod(stage, 0o700) + try: + trust_path = stage / "allowed_signers" + write_exclusive(trust_path, self.trust_bytes) + + verified: dict[str, tuple[Path, str]] = {} + for tool in tools: + archive, _, signature_digest = self._ensure_artifact( + tool, stage, trust_path + ) + verified[tool["id"]] = (archive, signature_digest) + + for tool in tools: + self._check_legacy_directory(tool) + installed = self._install_path(tool) + if installed.exists() or installed.is_symlink(): + validate_install(installed, tool["layout"]) + + extracted: dict[str, Path] = {} + for tool in tools: + installed = self._install_path(tool) + if installed.exists(): + continue + destination = stage / f"install-{tool['id']}" + destination.mkdir(mode=0o700) + archive_path = verified[tool["id"]][0] + with open_regular( + archive_path, tool["size"], "verified executable archive" + ) as archive: + verify_artifact(archive, tool["size"], tool["sha256"]) + inspect_archive(archive, tool["layout"]) + extract_archive(archive, destination, tool["layout"]) + validate_install(destination, tool["layout"]) + extracted[tool["id"]] = destination + + for tool in tools: + destination = extracted.get(tool["id"]) + if destination is None: + continue + installed = self._install_path(tool) + try: + os.rename(destination, installed) + fsync_directory(installed.parent) + except FileExistsError: + validate_install(installed, tool["layout"]) + + report = self._report(tools, verified) + atomic_write( + report_destination, + json.dumps(report, indent=2, sort_keys=True).encode() + b"\n", + ) + finally: + shutil.rmtree(stage, ignore_errors=True) + + def _report( + self, tools: list[dict[str, Any]], verified: dict[str, tuple[Path, str]] + ) -> dict[str, Any]: + return { + "schema_version": REPORT_SCHEMA, + "tools": [ + { + "archive_sha256": tool["sha256"], + "archive_size": tool["size"], + "arch": tool["arch"], + "id": tool["id"], + "layout": tool["layout"], + "signature": { + "identity": tool["signature"]["identity"], + "namespace": tool["signature"]["namespace"], + "sha256": verified[tool["id"]][1], + "signer_fingerprint": tool["signature"]["signer_fingerprint"], + "type": "ssh", + "verified": True, + }, + "version": tool["version"], + } + for tool in tools + ], + } + + def resolve(self, tool_id: str) -> Path: + tool = self.tools.get(tool_id) + if tool is None: + fail("unknown executable tool") + self._check_legacy_directory(tool) + installed = self._install_path(tool) + validate_install(installed, tool["layout"]) + executable = (installed / tool_id).absolute() + if not executable.is_absolute() or tool["sha256"] not in executable.parts: + fail("executable path is not absolute and digest-bound") + return executable + + def run( + self, + tool_id: str, + arguments: list[str], + environment: Mapping[str, str] | None = None, + ) -> None: + if any( + not isinstance(argument, str) or "\x00" in argument + for argument in arguments + ): + fail("executable arguments must be NUL-free text") + if not fd_exec_supported(): + fail("file-descriptor execution is unavailable") + tool = self.tools.get(tool_id) + if tool is None: + fail("unknown executable tool") + executable_entry = next( + (entry for entry in tool["layout"] if entry["path"] == tool_id), None + ) + if executable_entry is None: + fail("locked executable member is absent from the install layout") + self._check_legacy_directory(tool) + installed = self._install_path(tool) + executable_environment = sanitized_environment( + os.environ if environment is None else environment + ) + with open_validated_executable( + installed, tool["layout"], tool_id + ) as source_descriptor: + with open_sealed_executable( + source_descriptor, executable_entry + ) as descriptor: + os.execve(descriptor, [tool_id, *arguments], executable_environment) + fail("file-descriptor execution unexpectedly returned") + + +def load_policy(lock: Path, trust: Path) -> tuple[dict[str, Any], bytes]: + fingerprint, trust_bytes = load_validated_trust(trust) + document = validate_lock(lock, fingerprint) + lock_bytes = ( + json.dumps(document, ensure_ascii=True, indent=2, sort_keys=True).encode() + + b"\n" + ) + committed_bytes(lock, lock_bytes) + committed_bytes(trust, trust_bytes) + return document, trust_bytes + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Authenticate executable bootstrap tools." + ) + parser.add_argument("--lock", type=Path, default=DEFAULT_LOCK) + parser.add_argument("--trust", type=Path, default=DEFAULT_TRUST) + parser.add_argument("--workdir", type=Path, required=True) + subparsers = parser.add_subparsers(dest="command", required=True) + install = subparsers.add_parser("install") + install.add_argument("--report", type=Path, required=True) + install.add_argument("tools", nargs="+") + resolve = subparsers.add_parser("resolve") + resolve.add_argument("tool") + run = subparsers.add_parser("run") + run.add_argument("tool") + run.add_argument("arguments", nargs=argparse.REMAINDER) + # argparse consumes the one CLI `--`; any subsequent `--` is tool data. + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(sys.argv[1:] if argv is None else argv) + try: + document, trust_bytes = load_policy(args.lock, args.trust) + bootstrapper = Bootstrapper(document, trust_bytes, args.workdir) + if args.command == "install": + bootstrapper.install(args.tools, args.report) + print("Executable bootstrap verification succeeded.") + elif args.command == "resolve": + print(bootstrapper.resolve(args.tool)) + else: + bootstrapper.run(args.tool, args.arguments) + except (BootstrapError, ValidationError) as exc: + print(f"executable bootstrap failed: {exc}", file=sys.stderr) + return 1 + except Exception: + print("executable bootstrap failed: internal operation failed", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/bootstrap_io.py b/src/bootstrap_io.py new file mode 100644 index 00000000..c97d2ae4 --- /dev/null +++ b/src/bootstrap_io.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +"""Private I/O, committed-policy, download, and OpenSSH bootstrap helpers.""" + +from __future__ import annotations + +import hashlib +import os +from pathlib import Path +import secrets +import stat +import subprocess +from typing import Any, BinaryIO +import urllib.parse +import urllib.request + +from bootstrap_archive import BootstrapError + + +MAX_SIGNATURE_BYTES = 64 * 1024 +DOWNLOAD_CHUNK = 1024 * 1024 +SYSTEM_GIT = Path("/usr/bin/git") +SYSTEM_SSH_KEYGEN = Path("/usr/bin/ssh-keygen") + + +def fail(message: str) -> None: + raise BootstrapError(message) + + +def ensure_private_directory(path: Path) -> None: + try: + path.mkdir(mode=0o700, parents=True, exist_ok=True) + info = path.lstat() + except OSError as exc: + fail(f"cannot create private bootstrap directory: {exc.strerror or exc}") + if not stat.S_ISDIR(info.st_mode) or stat.S_IMODE(info.st_mode) & 0o077: + fail("bootstrap directory is not a private real directory") + + +def fsync_directory(path: Path) -> None: + descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + + +def write_exclusive(path: Path, data: bytes, mode: int = 0o600) -> None: + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW + descriptor = os.open(path, flags, mode) + try: + view = memoryview(data) + while view: + written = os.write(descriptor, view) + if written <= 0: + fail("short bootstrap metadata write") + view = view[written:] + os.fsync(descriptor) + finally: + os.close(descriptor) + + +def atomic_write(path: Path, data: bytes, mode: int = 0o600) -> None: + ensure_private_directory(path.parent) + temporary = path.parent / f".new-{secrets.token_hex(16)}" + try: + write_exclusive(temporary, data, mode) + os.replace(temporary, path) + fsync_directory(path.parent) + finally: + try: + temporary.unlink() + except FileNotFoundError: + pass + + +def committed_bytes(path: Path, captured: bytes) -> None: + """Require captured policy bytes to equal the clean HEAD blob.""" + if not SYSTEM_GIT.is_file() or SYSTEM_GIT.is_symlink(): + fail("the system Git verifier is unavailable") + repository = subprocess.run( + [str(SYSTEM_GIT), "-C", str(path.parent), "rev-parse", "--show-toplevel"], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + ) + if repository.returncode != 0: + fail("bootstrap policy is not inside a Git checkout") + root = Path(repository.stdout.strip()).resolve() + resolved = path.resolve() + try: + relative = resolved.relative_to(root).as_posix() + except ValueError: + fail("bootstrap policy is outside the repository") + blob = subprocess.run( + [str(SYSTEM_GIT), "-C", str(root), "show", f"HEAD:{relative}"], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + ) + if blob.returncode != 0 or blob.stdout != captured: + fail("bootstrap policy must exactly match its committed HEAD blob") + + +def download_https(url: str, destination: Path, maximum: int) -> tuple[int, str]: + parsed = urllib.parse.urlsplit(url) + if ( + parsed.scheme != "https" + or not parsed.hostname + or parsed.username + or parsed.password + ): + fail("bootstrap download URL is not strict HTTPS") + request = urllib.request.Request( + url, headers={"User-Agent": "PixeneOS-bootstrap/1"} + ) + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW + descriptor = os.open(destination, flags, 0o600) + digest = hashlib.sha256() + total = 0 + try: + with urllib.request.urlopen(request, timeout=60) as response: + final = urllib.parse.urlsplit(response.geturl()) + if final.scheme != "https" or not final.hostname: + fail("bootstrap redirect left HTTPS") + if response.headers.get("Content-Encoding", "identity") not in ( + "", + "identity", + ): + fail("encoded bootstrap responses are forbidden") + declared = response.headers.get("Content-Length") + if declared is not None: + try: + if int(declared) < 0 or int(declared) > maximum: + fail("bootstrap response exceeds its byte limit") + except ValueError: + fail("bootstrap response has an invalid length") + while True: + chunk = response.read(DOWNLOAD_CHUNK) + if not chunk: + break + total += len(chunk) + if total > maximum: + fail("bootstrap response exceeds its byte limit") + view = memoryview(chunk) + while view: + written = os.write(descriptor, view) + if written <= 0: + fail("short bootstrap download write") + view = view[written:] + digest.update(chunk) + os.fsync(descriptor) + except BootstrapError: + try: + destination.unlink() + except FileNotFoundError: + pass + raise + except Exception: + try: + destination.unlink() + except FileNotFoundError: + pass + fail("bootstrap HTTPS acquisition failed") + finally: + os.close(descriptor) + return total, digest.hexdigest() + + +def verify_ssh_signature( + archive: BinaryIO, signature: BinaryIO, trust: BinaryIO, tool: dict[str, Any] +) -> None: + if ( + not SYSTEM_SSH_KEYGEN.is_file() + or SYSTEM_SSH_KEYGEN.is_symlink() + or not Path("/proc/self/fd").is_dir() + ): + fail("OpenSSH signature verification is unavailable") + archive.seek(0) + signature.seek(0) + trust.seek(0) + command = [ + str(SYSTEM_SSH_KEYGEN), + "-Y", + "verify", + "-f", + f"/proc/self/fd/{trust.fileno()}", + "-I", + tool["signature"]["identity"], + "-n", + tool["signature"]["namespace"], + "-s", + f"/proc/self/fd/{signature.fileno()}", + ] + result = subprocess.run( + command, + stdin=archive, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + close_fds=True, + pass_fds=(signature.fileno(), trust.fileno()), + check=False, + ) + archive.seek(0) + signature.seek(0) + trust.seek(0) + if result.returncode != 0: + fail("OpenSSH signature verification failed") diff --git a/src/validate_executable_tool_lock.py b/src/validate_executable_tool_lock.py index bb0acf21..1f926260 100644 --- a/src/validate_executable_tool_lock.py +++ b/src/validate_executable_tool_lock.py @@ -27,7 +27,9 @@ SIGNATURE_NAMESPACE = "file" SIGNATURE_TYPE = "ssh" SIGNER_KEY_TYPE = "ssh-ed25519" -SIGNER_PUBLIC_KEY = "AAAAC3NzaC1lZDI1NTE5AAAAIDOe6/tBnO7xZhAWXRj3ApUYgn+XZ0wnQiXM8B7tPgv4" +SIGNER_PUBLIC_KEY = ( + "AAAAC3NzaC1lZDI1NTE5AAAAIDOe6/tBnO7xZhAWXRj3ApUYgn+XZ0wnQiXM8B7tPgv4" +) SIGNER_FINGERPRINT = "SHA256:Ct0HoRyrFLrnF9W+A/BKEiJmwx7yWkgaW/JvghKrboA" MAX_LOCK_BYTES = 1024 * 1024 @@ -86,7 +88,9 @@ def fail(message: str) -> None: raise ValidationError(message) -def require_exact_fields(value: Any, expected: frozenset[str], context: str) -> dict[str, Any]: +def require_exact_fields( + value: Any, expected: frozenset[str], context: str +) -> dict[str, Any]: if type(value) is not dict: fail(f"{context} must be an object") actual = frozenset(value) @@ -182,11 +186,14 @@ def parse_ssh_string(blob: bytes, offset: int, context: str) -> tuple[bytes, int return blob[offset:end], end -def validate_trust(path: Path) -> str: - raw = read_regular_file(path, MAX_TRUST_BYTES, "trust file") - expected_line = f"{SIGNER_IDENTITY} {SIGNER_KEY_TYPE} {SIGNER_PUBLIC_KEY}\n".encode("ascii") +def validate_trust_bytes(raw: bytes) -> str: + expected_line = f"{SIGNER_IDENTITY} {SIGNER_KEY_TYPE} {SIGNER_PUBLIC_KEY}\n".encode( + "ascii" + ) if raw != expected_line: - fail("trust file must contain exactly the reviewed chenxiaolong allowed-signer binding") + fail( + "trust file must contain exactly the reviewed chenxiaolong allowed-signer binding" + ) try: key_blob = base64.b64decode(SIGNER_PUBLIC_KEY, validate=True) @@ -202,13 +209,25 @@ def validate_trust(path: Path) -> str: if len(public_key) != 32: fail("trusted Ed25519 public key must contain exactly 32 key bytes") - encoded_digest = base64.b64encode(hashlib.sha256(key_blob).digest()).decode("ascii").rstrip("=") + encoded_digest = ( + base64.b64encode(hashlib.sha256(key_blob).digest()).decode("ascii").rstrip("=") + ) fingerprint = f"SHA256:{encoded_digest}" if fingerprint != SIGNER_FINGERPRINT: fail("trusted SSH key does not match the reviewed fingerprint") return fingerprint +def load_validated_trust(path: Path) -> tuple[str, bytes]: + raw = read_regular_file(path, MAX_TRUST_BYTES, "trust file") + return validate_trust_bytes(raw), raw + + +def validate_trust(path: Path) -> str: + fingerprint, _ = load_validated_trust(path) + return fingerprint + + def canonical_release_url(repository: str, version: str, artifact_name: str) -> str: return ( f"https://github.com/chenxiaolong/{repository}/releases/download/" @@ -251,7 +270,9 @@ def validate_layout(value: Any, tool_id: str, policy: dict[str, Any]) -> None: if paths != sorted(paths): fail(f"tool {tool_id} layout paths must be sorted") if modes != policy["layout"]: - fail(f"tool {tool_id} layout paths or modes do not match the reviewed release layout") + fail( + f"tool {tool_id} layout paths or modes do not match the reviewed release layout" + ) def validate_tool(raw_tool: Any, index: int, fingerprint: str) -> str: @@ -295,7 +316,7 @@ def validate_tool(raw_tool: Any, index: int, fingerprint: str) -> str: return tool_id -def validate_lock(path: Path, fingerprint: str) -> None: +def validate_lock(path: Path, fingerprint: str) -> dict[str, Any]: raw = read_regular_file(path, MAX_LOCK_BYTES, "lock file") try: text = raw.decode("utf-8") @@ -308,33 +329,47 @@ def validate_lock(path: Path, fingerprint: str) -> None: parse_constant=reject_nonstandard_number, ) except json.JSONDecodeError as exc: - fail(f"lock file is not valid JSON: {exc.msg} at line {exc.lineno} column {exc.colno}") + fail( + f"lock file is not valid JSON: {exc.msg} at line {exc.lineno} column {exc.colno}" + ) document = require_exact_fields(data, TOP_LEVEL_FIELDS, "lock") - if type(document["schema_version"]) is not int or document["schema_version"] != SCHEMA_VERSION: + if ( + type(document["schema_version"]) is not int + or document["schema_version"] != SCHEMA_VERSION + ): fail(f"lock schema_version must be {SCHEMA_VERSION}") tools = document["tools"] if type(tools) is not list: fail("lock tools must be an array") - tool_ids = [validate_tool(tool, index, fingerprint) for index, tool in enumerate(tools)] + tool_ids = [ + validate_tool(tool, index, fingerprint) for index, tool in enumerate(tools) + ] if len(tool_ids) != len(set(tool_ids)): fail("lock contains duplicate tool ids") expected_ids = sorted(TOOL_POLICY) if tool_ids != expected_ids: - fail(f"lock tools must contain exactly these ids in order: {', '.join(expected_ids)}") + fail( + f"lock tools must contain exactly these ids in order: {', '.join(expected_ids)}" + ) canonical = json.dumps(document, ensure_ascii=True, indent=2, sort_keys=True) + "\n" if text != canonical: fail("lock file is not in canonical sorted-key JSON form") + return document def parse_args(argv: list[str]) -> argparse.Namespace: parser = argparse.ArgumentParser( description="Validate the executable-tool lock and SSH trust binding without network access." ) - parser.add_argument("--lock", type=Path, default=DEFAULT_LOCK, help="lock JSON path") - parser.add_argument("--trust", type=Path, default=DEFAULT_TRUST, help="allowed-signers path") + parser.add_argument( + "--lock", type=Path, default=DEFAULT_LOCK, help="lock JSON path" + ) + parser.add_argument( + "--trust", type=Path, default=DEFAULT_TRUST, help="allowed-signers path" + ) return parser.parse_args(argv) diff --git a/tests/executable_tool_bootstrap_test.py b/tests/executable_tool_bootstrap_test.py new file mode 100644 index 00000000..b8633525 --- /dev/null +++ b/tests/executable_tool_bootstrap_test.py @@ -0,0 +1,482 @@ +#!/usr/bin/env python3 +"""Runtime crypto, cache, batching, race, and reporting tests for Tranche B.""" + +from __future__ import annotations + +import copy +import hashlib +import io +import json +import os +from pathlib import Path +import shutil +import stat +import subprocess +import sys +import tempfile +import unittest +from unittest import mock +import zipfile + + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT / "src")) + +from bootstrap_archive import BootstrapError # noqa: E402 +from bootstrap_executable_tools import ( # noqa: E402 + Bootstrapper, + parse_args, + verify_ssh_signature, + write_exclusive, +) + + +def archive_bytes(name: str, payload: bytes, mode: int = 0o755) -> bytes: + output = io.BytesIO() + info = zipfile.ZipInfo(name, (2026, 7, 20, 0, 0, 0)) + info.create_system = 3 + info.compress_type = zipfile.ZIP_DEFLATED + info.external_attr = (stat.S_IFREG | mode) << 16 + with zipfile.ZipFile(output, "w") as archive: + archive.writestr(info, payload) + return output.getvalue() + + +def tool_entry(tool_id: str, archive: bytes, payload: bytes) -> dict[str, object]: + return { + "arch": "fixture-linux", + "artifact_name": f"{tool_id}.zip", + "id": tool_id, + "layout": [ + { + "mode": "0755", + "path": tool_id, + "sha256": hashlib.sha256(payload).hexdigest(), + "size": len(payload), + "type": "file", + } + ], + "sha256": hashlib.sha256(archive).hexdigest(), + "signature": { + "identity": "tester", + "namespace": "file", + "signer_fingerprint": "fixture-fingerprint", + "type": "ssh", + "url": f"https://fixtures.invalid/{tool_id}.zip.sig", + }, + "size": len(archive), + "url": f"https://fixtures.invalid/{tool_id}.zip", + "version": "1", + } + + +class BootstrapRuntimeTest(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.root = Path(self.temporary.name) + self.work = self.root / "work" + self.key = self.root / "signing-key" + self.other_key = self.root / "other-key" + for key in (self.key, self.other_key): + subprocess.run( + ["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(key)], + check=True, + ) + key_parts = self.key.with_suffix(".pub").read_text().split() + self.trust = f"tester {key_parts[0]} {key_parts[1]}\n".encode() + + self.payloads = { + "fixture-a": b"alpha executable", + "fixture-b": b"beta executable", + } + self.archives = { + name: archive_bytes(name, payload) + for name, payload in self.payloads.items() + } + self.tools = { + name: tool_entry(name, self.archives[name], payload) + for name, payload in self.payloads.items() + } + self.signatures = { + name: self.sign(self.archives[name], self.key, name) + for name in self.archives + } + self.mapping: dict[str, bytes] = {} + for name, tool in self.tools.items(): + self.mapping[str(tool["url"])] = self.archives[name] + signature = tool["signature"] + assert isinstance(signature, dict) + self.mapping[str(signature["url"])] = self.signatures[name] + + def tearDown(self) -> None: + self.temporary.cleanup() + + def sign(self, data: bytes, key: Path, label: str) -> bytes: + source = self.root / f"{label}-{key.name}.bin" + source.write_bytes(data) + subprocess.run( + ["ssh-keygen", "-Y", "sign", "-f", str(key), "-n", "file", str(source)], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return Path(f"{source}.sig").read_bytes() + + def document(self) -> dict[str, object]: + return { + "schema_version": 1, + "tools": [copy.deepcopy(self.tools[name]) for name in sorted(self.tools)], + } + + def downloader(self, mapping: dict[str, bytes] | None = None): + values = self.mapping if mapping is None else mapping + + def download(url: str, destination: Path, maximum: int) -> tuple[int, str]: + if url not in values: + raise BootstrapError("fixture download missing") + data = values[url] + write_exclusive(destination, data) + return len(data), hashlib.sha256(data).hexdigest() + + return download + + def bootstrapper(self, **kwargs) -> Bootstrapper: + return Bootstrapper( + self.document(), + self.trust, + self.work, + downloader=kwargs.pop("downloader", self.downloader()), + verifier=kwargs.pop("verifier", verify_ssh_signature), + **kwargs, + ) + + def report_path(self) -> Path: + return self.work / "reports" / "bootstrap.json" + + def test_real_signatures_batch_install_resolve_and_report_are_deterministic( + self, + ) -> None: + bootstrapper = self.bootstrapper() + bootstrapper.install(sorted(self.tools), self.report_path()) + first_report = self.report_path().read_bytes() + + for name, tool in self.tools.items(): + executable = bootstrapper.resolve(name) + self.assertTrue(executable.is_absolute()) + self.assertIn(str(tool["sha256"]), executable.parts) + self.assertEqual(executable.read_bytes(), self.payloads[name]) + self.assertEqual(stat.S_IMODE(executable.stat().st_mode), 0o755) + + bootstrapper.install(sorted(self.tools), self.report_path()) + self.assertEqual(first_report, self.report_path().read_bytes()) + report = json.loads(first_report) + encoded = first_report.decode() + self.assertEqual([item["id"] for item in report["tools"]], sorted(self.tools)) + self.assertNotIn(str(self.work), encoded) + self.assertNotIn("https://", encoded) + + def test_size_digest_and_signature_are_required_with_and_semantics(self) -> None: + cases = [] + wrong_size = self.document() + wrong_size["tools"][0]["size"] += 1 # type: ignore[index] + cases.append(("wrong-size", wrong_size, self.mapping)) + wrong_digest = self.document() + wrong_digest["tools"][0]["sha256"] = "0" * 64 # type: ignore[index] + cases.append(("wrong-digest", wrong_digest, self.mapping)) + wrong_signature = dict(self.mapping) + first = self.tools["fixture-a"]["signature"] + assert isinstance(first, dict) + wrong_signature[str(first["url"])] = self.signatures["fixture-b"] + cases.append(("wrong-signature", self.document(), wrong_signature)) + wrong_namespace = self.document() + wrong_namespace["tools"][0]["signature"]["namespace"] = "git" # type: ignore[index] + cases.append(("wrong-namespace", wrong_namespace, self.mapping)) + wrong_identity = self.document() + wrong_identity["tools"][0]["signature"]["identity"] = "other" # type: ignore[index] + cases.append(("wrong-identity", wrong_identity, self.mapping)) + + for label, document, mapping in cases: + with self.subTest(label=label): + work = self.root / label + bootstrapper = Bootstrapper( + document, + self.trust, + work, + downloader=self.downloader(mapping), + ) + with self.assertRaises(BootstrapError): + bootstrapper.install(sorted(self.tools), work / "report.json") + self.assertFalse( + (work / "tools" / "by-sha256").exists() + and any((work / "tools" / "by-sha256").iterdir()) + ) + + def test_validly_signed_wrong_artifact_is_rejected_before_extraction(self) -> None: + wrong = archive_bytes("fixture-a", b"validly signed but wrong") + mapping = dict(self.mapping) + tool = self.tools["fixture-a"] + signature = tool["signature"] + assert isinstance(signature, dict) + mapping[str(tool["url"])] = wrong + mapping[str(signature["url"])] = self.sign(wrong, self.key, "wrong-artifact") + bootstrapper = self.bootstrapper(downloader=self.downloader(mapping)) + with self.assertRaises(BootstrapError): + bootstrapper.install(sorted(self.tools), self.report_path()) + store = self.work / "tools" / "by-sha256" + self.assertFalse(store.exists() and any(store.iterdir())) + + def test_missing_and_wrong_key_signatures_fail_without_installation(self) -> None: + for label, mapping in ( + ("missing", dict(self.mapping)), + ("malformed", dict(self.mapping)), + ("wrong-key", dict(self.mapping)), + ): + tool = self.tools["fixture-a"] + signature = tool["signature"] + assert isinstance(signature, dict) + if label == "missing": + del mapping[str(signature["url"])] + elif label == "malformed": + mapping[str(signature["url"])] = b"not an OpenSSH signature" + else: + mapping[str(signature["url"])] = self.sign( + self.archives["fixture-a"], self.other_key, "wrong-key-signature" + ) + work = self.root / label + bootstrapper = Bootstrapper( + self.document(), self.trust, work, downloader=self.downloader(mapping) + ) + with self.assertRaises(BootstrapError): + bootstrapper.install(sorted(self.tools), work / "report.json") + self.assertFalse( + (work / "tools" / "by-sha256").exists() + and any((work / "tools" / "by-sha256").iterdir()) + ) + + def test_late_failure_prevents_all_extraction_chmod_publication_and_execution( + self, + ) -> None: + sentinel = self.root / "executed" + self.payloads["fixture-a"] = f"#!/bin/sh\ntouch {sentinel}\n".encode() + self.archives["fixture-a"] = archive_bytes( + "fixture-a", self.payloads["fixture-a"] + ) + self.tools["fixture-a"] = tool_entry( + "fixture-a", self.archives["fixture-a"], self.payloads["fixture-a"] + ) + tool_a = self.tools["fixture-a"] + signature_a = tool_a["signature"] + assert isinstance(signature_a, dict) + self.mapping[str(tool_a["url"])] = self.archives["fixture-a"] + self.mapping[str(signature_a["url"])] = self.sign( + self.archives["fixture-a"], self.key, "sentinel" + ) + tool_b = self.tools["fixture-b"] + signature_b = tool_b["signature"] + assert isinstance(signature_b, dict) + self.mapping[str(signature_b["url"])] = b"late invalid signature" + + bootstrapper = self.bootstrapper() + with mock.patch("bootstrap_executable_tools.extract_archive") as extract: + with mock.patch("bootstrap_archive.os.fchmod") as chmod: + with self.assertRaises(BootstrapError): + bootstrapper.install(sorted(self.tools), self.report_path()) + extract.assert_not_called() + chmod.assert_not_called() + store = self.work / "tools" / "by-sha256" + self.assertFalse(store.exists() and any(store.iterdir())) + self.assertFalse(sentinel.exists()) + + def test_corrupt_cache_and_installed_directory_cannot_bypass_revalidation( + self, + ) -> None: + bootstrapper = self.bootstrapper() + bootstrapper.install(sorted(self.tools), self.report_path()) + tool = self.tools["fixture-a"] + archive_object = ( + self.work / "bootstrap-cache" / "objects" / "archives" / str(tool["sha256"]) + ) + archive_object.write_bytes(b"X" * int(tool["size"])) + shutil.rmtree(self.work / "tools" / "by-sha256") + with self.assertRaises(BootstrapError): + bootstrapper.install(sorted(self.tools), self.report_path()) + store = self.work / "tools" / "by-sha256" + self.assertFalse(store.exists() and any(store.iterdir())) + + clean_work = self.root / "installed-bypass" + clean = Bootstrapper( + self.document(), self.trust, clean_work, downloader=self.downloader() + ) + clean.install(sorted(self.tools), clean_work / "report.json") + executable = clean.resolve("fixture-a") + executable.write_bytes(b"Z" * len(self.payloads["fixture-a"])) + with self.assertRaises(BootstrapError): + clean.install(sorted(self.tools), clean_work / "report.json") + with self.assertRaises(BootstrapError): + clean.resolve("fixture-a") + + def test_archive_replacement_race_never_publishes_or_extracts_replacement( + self, + ) -> None: + replaced = False + + def racing_verifier(archive, signature, trust, tool) -> None: + nonlocal replaced + verify_ssh_signature(archive, signature, trust, tool) + if not replaced: + opened_path = Path(os.readlink(f"/proc/self/fd/{archive.fileno()}")) + replacement = opened_path.with_name("replacement") + replacement.write_bytes(b"malicious replacement") + os.replace(replacement, opened_path) + replaced = True + + bootstrapper = self.bootstrapper(verifier=racing_verifier) + with self.assertRaises(BootstrapError): + bootstrapper.install(sorted(self.tools), self.report_path()) + store = self.work / "tools" / "by-sha256" + self.assertFalse(store.exists() and any(store.iterdir())) + + def test_legacy_preinstalled_directory_is_revalidated_and_rejected(self) -> None: + legacy = self.work / "tools" / "fixture-a" + legacy.mkdir(parents=True) + executable = legacy / "fixture-a" + executable.write_bytes(b"untrusted") + executable.chmod(0o755) + bootstrapper = self.bootstrapper() + with self.assertRaises(BootstrapError): + bootstrapper.install(sorted(self.tools), self.report_path()) + store = self.work / "tools" / "by-sha256" + self.assertFalse(store.exists() and any(store.iterdir())) + + def test_run_executes_validated_fd_with_exact_argv_and_sanitized_env(self) -> None: + bootstrapper = self.bootstrapper() + bootstrapper.install(sorted(self.tools), self.report_path()) + source_environment = { + "PATH": "/custom/bin", + "SIGNING_PASSPHRASE": "preserved secret", + "LD_PRELOAD": "/untrusted/inject.so", + "DYLD_INSERT_LIBRARIES": "/untrusted/inject.dylib", + "GLIBC_TUNABLES": "glibc.malloc.check=3", + "RUST_LOG": "trace", + } + observed: dict[str, object] = {} + + def fake_execve(descriptor, arguments, environment) -> None: + observed["descriptor"] = descriptor + observed["arguments"] = arguments + observed["environment"] = environment + os.lseek(descriptor, 0, os.SEEK_SET) + observed["payload"] = os.read(descriptor, 4096) + + with mock.patch( + "bootstrap_executable_tools.fd_exec_supported", return_value=True + ): + with mock.patch("bootstrap_executable_tools.os.execve", fake_execve): + with self.assertRaises(BootstrapError): + bootstrapper.run( + "fixture-a", + ["argument with spaces", "$(not-a-shell)", "--flag"], + source_environment, + ) + + self.assertIsInstance(observed["descriptor"], int) + self.assertEqual( + observed["arguments"], + ["fixture-a", "argument with spaces", "$(not-a-shell)", "--flag"], + ) + self.assertEqual(observed["payload"], self.payloads["fixture-a"]) + environment = observed["environment"] + assert isinstance(environment, dict) + self.assertEqual(environment["PATH"], "/custom/bin") + self.assertEqual(environment["SIGNING_PASSPHRASE"], "preserved secret") + for forbidden in ( + "LD_PRELOAD", + "DYLD_INSERT_LIBRARIES", + "GLIBC_TUNABLES", + "RUST_LOG", + ): + self.assertNotIn(forbidden, environment) + + def test_run_executes_sealed_bytes_after_same_inode_source_overwrite(self) -> None: + bootstrapper = self.bootstrapper() + bootstrapper.install(sorted(self.tools), self.report_path()) + executable = bootstrapper.resolve("fixture-a") + original_inode = executable.stat().st_ino + observed: dict[str, object] = {} + + def overwrite_then_exec(descriptor, arguments, environment) -> None: + executable.write_bytes(b"Z" * len(self.payloads["fixture-a"])) + os.lseek(descriptor, 0, os.SEEK_SET) + observed["inode"] = os.fstat(descriptor).st_ino + observed["payload"] = os.read(descriptor, 4096) + + with mock.patch( + "bootstrap_executable_tools.fd_exec_supported", return_value=True + ): + with mock.patch( + "bootstrap_executable_tools.os.execve", overwrite_then_exec + ): + with self.assertRaises(BootstrapError): + bootstrapper.run("fixture-a", [], {"PATH": "/usr/bin"}) + + self.assertNotEqual(observed["inode"], original_inode) + self.assertEqual(observed["payload"], self.payloads["fixture-a"]) + self.assertEqual( + executable.read_bytes(), b"Z" * len(self.payloads["fixture-a"]) + ) + + def test_run_failure_never_calls_execve(self) -> None: + bootstrapper = self.bootstrapper() + bootstrapper.install(sorted(self.tools), self.report_path()) + executable = bootstrapper.resolve("fixture-a") + executable.write_bytes(b"X" * len(self.payloads["fixture-a"])) + + with mock.patch( + "bootstrap_executable_tools.fd_exec_supported", return_value=True + ): + with mock.patch("bootstrap_executable_tools.os.execve") as execve: + with self.assertRaises(BootstrapError): + bootstrapper.run("fixture-a", [], {}) + execve.assert_not_called() + + def test_run_rejects_unsupported_fd_exec_and_nul_arguments(self) -> None: + bootstrapper = self.bootstrapper() + with mock.patch( + "bootstrap_executable_tools.fd_exec_supported", return_value=False + ): + with mock.patch("bootstrap_executable_tools.os.execve") as execve: + with self.assertRaises(BootstrapError): + bootstrapper.run("fixture-a", [], {}) + execve.assert_not_called() + + with mock.patch("bootstrap_executable_tools.os.execve") as execve: + with self.assertRaises(BootstrapError): + bootstrapper.run("fixture-a", ["bad\x00argument"], {}) + execve.assert_not_called() + + with mock.patch( + "bootstrap_executable_tools.open_sealed_executable", + side_effect=BootstrapError("sealing failed"), + ): + with mock.patch("bootstrap_executable_tools.os.execve") as execve: + with self.assertRaises(BootstrapError): + bootstrapper.run("fixture-a", [], {}) + execve.assert_not_called() + + def test_run_parser_strips_only_one_separator(self) -> None: + args = parse_args( + [ + "--workdir", + str(self.work), + "run", + "fixture-a", + "--", + "--", + "literal", + ] + ) + self.assertEqual(args.arguments, ["--", "literal"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/executable_tool_bootstrap_test.sh b/tests/executable_tool_bootstrap_test.sh new file mode 100644 index 00000000..b00d7691 --- /dev/null +++ b/tests/executable_tool_bootstrap_test.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 PixeneOS contributors + +set -euo pipefail + +python3 tests/test_bootstrap_archive.py +python3 tests/executable_tool_bootstrap_test.py + +echo "executable tool bootstrap tests passed" diff --git a/tests/test_bootstrap_archive.py b/tests/test_bootstrap_archive.py new file mode 100644 index 00000000..58e5c27c --- /dev/null +++ b/tests/test_bootstrap_archive.py @@ -0,0 +1,436 @@ +#!/usr/bin/env python3 +"""Adversarial tests for executable-bootstrap ZIP validation and extraction.""" + +from __future__ import annotations + +from contextlib import contextmanager +import fcntl +import hashlib +import io +import os +from pathlib import Path +import stat +import struct +import sys +import tempfile +import unittest +from unittest import mock +import warnings +import zipfile + + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT / "src")) + +from bootstrap_archive import ( # noqa: E402 + BootstrapError, + extract_archive, + inspect_archive, + open_sealed_executable, + open_validated_executable, + validate_install, +) + + +TOOL_BYTES = b"#!/bin/sh\nprintf 'fixture tool\\n'\n" + + +def locked_member( + name: str = "tool", data: bytes = TOOL_BYTES, mode: int = 0o755 +) -> dict[str, object]: + return { + "mode": f"0{mode:o}", + "path": name, + "sha256": hashlib.sha256(data).hexdigest(), + "size": len(data), + "type": "file", + } + + +def zip_info( + name: str, + mode: int = stat.S_IFREG | 0o755, + compression: int = zipfile.ZIP_DEFLATED, +) -> zipfile.ZipInfo: + info = zipfile.ZipInfo(name, date_time=(2020, 1, 1, 0, 0, 0)) + info.create_system = 3 + info.external_attr = mode << 16 + info.compress_type = compression + return info + + +def make_archive( + entries: list[tuple[str, bytes, int]], + compression: int = zipfile.ZIP_DEFLATED, +) -> bytes: + stream = io.BytesIO() + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + with zipfile.ZipFile(stream, "w") as archive: + for name, data, mode in entries: + archive.writestr(zip_info(name, mode, compression), data) + return stream.getvalue() + + +def patch_single_member_headers( + archive: bytes, + *, + flags: int | None = None, + compression: int | None = None, +) -> bytes: + """Patch matching local/central fields without rebuilding the ZIP.""" + result = bytearray(archive) + local = result.find(b"PK\x03\x04") + central = result.find(b"PK\x01\x02") + if local < 0 or central < 0: + raise AssertionError("fixture ZIP lacks expected headers") + if flags is not None: + struct.pack_into(" bytes: + """Patch only local-header fields to create central/local contradictions.""" + result = bytearray(archive) + local = result.find(b"PK\x03\x04") + if local < 0: + raise AssertionError("fixture ZIP lacks a local member header") + if flags is not None: + struct.pack_into(" None: + with self.assertRaises(BootstrapError): + inspect_archive(io.BytesIO(archive), layout or [locked_member()]) + + def test_safe_exact_layout_inspects_extracts_and_validates(self) -> None: + readme = b"reviewed fixture\n" + layout = [ + locked_member("README.md", readme, 0o644), + locked_member(), + ] + archive = make_archive( + [ + ("README.md", readme, stat.S_IFREG | 0o644), + ("tool", TOOL_BYTES, stat.S_IFREG | 0o755), + ] + ) + stream = io.BytesIO(archive) + + inspect_archive(stream, layout) + with tempfile.TemporaryDirectory() as temporary: + destination = Path(temporary) / "installed" + destination.mkdir(mode=0o700) + extract_archive(stream, destination, layout) + validate_install(destination, layout) + + self.assertEqual((destination / "tool").read_bytes(), TOOL_BYTES) + self.assertEqual((destination / "README.md").read_bytes(), readme) + self.assertEqual(stat.S_IMODE((destination / "tool").stat().st_mode), 0o755) + self.assertEqual( + stat.S_IMODE((destination / "README.md").stat().st_mode), 0o644 + ) + + def test_unsafe_member_names_are_rejected(self) -> None: + for name in ("../tool", "/tool", "dir/tool", "tool\\alias", "./tool"): + with self.subTest(name=name): + archive = make_archive([(name, TOOL_BYTES, stat.S_IFREG | 0o755)]) + self.assert_rejected(archive) + + def test_duplicate_exact_and_normalized_names_are_rejected(self) -> None: + layout = [locked_member(), locked_member("placeholder", b"x", 0o644)] + exact = make_archive( + [ + ("tool", TOOL_BYTES, stat.S_IFREG | 0o755), + ("tool", TOOL_BYTES, stat.S_IFREG | 0o755), + ] + ) + normalized_alias = make_archive( + [ + ("tool", TOOL_BYTES, stat.S_IFREG | 0o755), + ("./tool", TOOL_BYTES, stat.S_IFREG | 0o755), + ] + ) + self.assert_rejected(exact, layout) + self.assert_rejected(normalized_alias, layout) + + def test_links_special_files_and_directories_are_rejected(self) -> None: + hostile = { + "symlink": ("tool", b"target", stat.S_IFLNK | 0o777), + "fifo": ("tool", b"", stat.S_IFIFO | 0o644), + "directory": ("tool/", b"", stat.S_IFDIR | 0o755), + } + for case_name, entry in hostile.items(): + with self.subTest(case=case_name): + self.assert_rejected(make_archive([entry])) + + def test_extra_and_missing_members_are_rejected(self) -> None: + extra = make_archive( + [ + ("tool", TOOL_BYTES, stat.S_IFREG | 0o755), + ("surprise", b"extra", stat.S_IFREG | 0o644), + ] + ) + self.assert_rejected(extra) + + missing_layout = [locked_member(), locked_member("README.md", b"readme", 0o644)] + ordinary = make_archive([("tool", TOOL_BYTES, stat.S_IFREG | 0o755)]) + self.assert_rejected(ordinary, missing_layout) + + def test_encrypted_flag_is_rejected(self) -> None: + ordinary = make_archive([("tool", TOOL_BYTES, stat.S_IFREG | 0o755)]) + self.assert_rejected(patch_single_member_headers(ordinary, flags=0x0001)) + + def test_unsupported_compression_and_flags_are_rejected(self) -> None: + bzip2_archive = make_archive( + [("tool", TOOL_BYTES, stat.S_IFREG | 0o755)], + compression=zipfile.ZIP_BZIP2, + ) + self.assert_rejected(bzip2_archive) + + ordinary = make_archive( + [("tool", TOOL_BYTES, stat.S_IFREG | 0o755)], + compression=zipfile.ZIP_STORED, + ) + self.assert_rejected(patch_single_member_headers(ordinary, flags=0x0010)) + + def test_local_header_flags_and_compression_must_match_central_entry(self) -> None: + ordinary = make_archive([("tool", TOOL_BYTES, stat.S_IFREG | 0o755)]) + contradictory = { + "local-encryption": patch_single_member_local_header( + ordinary, flags=0x0001 + ), + "local-unsupported-flag": patch_single_member_local_header( + ordinary, flags=0x0010 + ), + "local-compression": patch_single_member_local_header( + ordinary, compression=zipfile.ZIP_BZIP2 + ), + } + for case_name, archive in contradictory.items(): + with self.subTest(case=case_name): + self.assert_rejected(archive) + + def test_compression_bomb_is_rejected(self) -> None: + expanded = b"A" * (1024 * 1024) + archive = make_archive( + [("tool", expanded, stat.S_IFREG | 0o755)], + compression=zipfile.ZIP_DEFLATED, + ) + self.assert_rejected(archive, [locked_member(data=expanded)]) + + def test_locked_member_size_hash_and_mode_must_all_match(self) -> None: + archive = make_archive([("tool", TOOL_BYTES, stat.S_IFREG | 0o755)]) + wrong_size = locked_member() + wrong_size["size"] = len(TOOL_BYTES) + 1 + wrong_hash = locked_member() + wrong_hash["sha256"] = "0" * 64 + wrong_mode = locked_member() + wrong_mode["mode"] = "0644" + + for case_name, layout in ( + ("size", [wrong_size]), + ("hash", [wrong_hash]), + ("mode", [wrong_mode]), + ): + with self.subTest(case=case_name): + self.assert_rejected(archive, layout) + + def test_corrupt_and_truncated_archives_are_rejected(self) -> None: + ordinary = make_archive([("tool", TOOL_BYTES, stat.S_IFREG | 0o755)]) + for case_name, archive in ( + ("garbage", b"not a ZIP"), + ("truncated", ordinary[:-12]), + ): + with self.subTest(case=case_name): + self.assert_rejected(archive) + + @contextmanager + def installed_fixture(self): + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) / "installed" + directory.mkdir(mode=0o700) + tool = directory / "tool" + tool.write_bytes(TOOL_BYTES) + tool.chmod(0o755) + yield directory, tool + + def test_install_validation_rejects_content_mode_and_extra_member(self) -> None: + for case_name in ("content", "mode", "extra"): + with self.subTest(case=case_name), self.installed_fixture() as fixture: + directory, tool = fixture + if case_name == "content": + tool.write_bytes(b"X" * len(TOOL_BYTES)) + elif case_name == "mode": + tool.chmod(0o775) + else: + (directory / "extra").write_bytes(b"unexpected") + with self.assertRaises(BootstrapError): + validate_install(directory, [locked_member()]) + + def test_install_validation_rejects_member_and_directory_symlinks(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + target = root / "target" + target.mkdir() + real_tool = target / "real-tool" + real_tool.write_bytes(TOOL_BYTES) + real_tool.chmod(0o755) + (target / "tool").symlink_to(real_tool) + with self.assertRaises(BootstrapError): + validate_install(target, [locked_member()]) + + alias = root / "installed-alias" + alias.symlink_to(target, target_is_directory=True) + with self.assertRaises(BootstrapError): + validate_install(alias, [locked_member()]) + + def test_extraction_refuses_preexisting_target(self) -> None: + archive = make_archive([("tool", TOOL_BYTES, stat.S_IFREG | 0o755)]) + stream = io.BytesIO(archive) + layout = [locked_member()] + inspect_archive(stream, layout) + with tempfile.TemporaryDirectory() as temporary: + destination = Path(temporary) / "installed" + destination.mkdir(mode=0o700) + existing = destination / "tool" + existing.write_bytes(b"do not replace") + + with self.assertRaises(FileExistsError): + extract_archive(stream, destination, layout) + self.assertEqual(existing.read_bytes(), b"do not replace") + + def test_validated_executable_fd_survives_path_replacement(self) -> None: + with self.installed_fixture() as fixture: + directory, tool = fixture + original = tool.stat() + + with open_validated_executable( + directory, [locked_member()], "tool" + ) as descriptor: + replacement = directory / "replacement" + replacement.write_bytes(b"malicious replacement") + replacement.chmod(0o755) + replacement.replace(tool) + + self.assertIsInstance(descriptor, int) + self.assertEqual(os.fstat(descriptor).st_ino, original.st_ino) + os.lseek(descriptor, 0, os.SEEK_SET) + self.assertEqual(os.read(descriptor, len(TOOL_BYTES)), TOOL_BYTES) + + def test_validated_executable_requires_locked_executable_member(self) -> None: + with self.installed_fixture() as fixture: + directory, _ = fixture + with self.assertRaises(BootstrapError): + with open_validated_executable(directory, [locked_member()], "missing"): + self.fail("an absent executable must never yield a descriptor") + + def test_validated_executable_requires_locked_execute_bits(self) -> None: + with self.installed_fixture() as fixture: + directory, tool = fixture + tool.chmod(0o644) + with self.assertRaises(BootstrapError): + with open_validated_executable( + directory, [locked_member(mode=0o644)], "tool" + ): + self.fail("a nonexecutable locked member must never be yielded") + + def test_extraction_failure_never_grants_locked_executable_mode(self) -> None: + archive = make_archive([("tool", TOOL_BYTES, stat.S_IFREG | 0o755)]) + layout = [locked_member()] + inspect_archive(io.BytesIO(archive), layout) + changed = make_archive([("tool", b"X" * len(TOOL_BYTES), stat.S_IFREG | 0o755)]) + + with tempfile.TemporaryDirectory() as temporary: + destination = Path(temporary) / "installed" + destination.mkdir(mode=0o700) + with mock.patch("bootstrap_archive.os.fchmod", wraps=os.fchmod) as chmod: + with self.assertRaises(BootstrapError): + extract_archive(io.BytesIO(changed), destination, layout) + + chmod.assert_not_called() + self.assertEqual( + stat.S_IMODE((destination / "tool").stat().st_mode) & 0o111, + 0, + ) + + def test_sealed_executable_survives_same_inode_source_overwrite(self) -> None: + with self.installed_fixture() as (directory, tool): + layout = [locked_member()] + with open_validated_executable(directory, layout, "tool") as source: + with open_sealed_executable(source, layout[0]) as executable: + tool.write_bytes(b"X" * len(TOOL_BYTES)) + os.lseek(executable, 0, os.SEEK_SET) + self.assertEqual(os.read(executable, len(TOOL_BYTES)), TOOL_BYTES) + seals = fcntl.fcntl(executable, fcntl.F_GET_SEALS) + required = ( + fcntl.F_SEAL_WRITE + | fcntl.F_SEAL_GROW + | fcntl.F_SEAL_SHRINK + | fcntl.F_SEAL_SEAL + ) + self.assertEqual(seals & required, required) + with self.assertRaises(OSError): + os.write(executable, b"X") + + def test_sealed_executable_closes_memfd_when_sealing_fails(self) -> None: + captured: list[int] = [] + original = os.memfd_create + + def capture_memfd(name: str, flags: int) -> int: + descriptor = original(name, flags) + captured.append(descriptor) + return descriptor + + with self.installed_fixture() as (directory, _): + layout = [locked_member()] + with open_validated_executable(directory, layout, "tool") as source: + with mock.patch("bootstrap_archive.os.memfd_create", capture_memfd): + with mock.patch( + "bootstrap_archive.fcntl.fcntl", side_effect=OSError("fail") + ): + with self.assertRaises(BootstrapError): + with open_sealed_executable(source, layout[0]): + self.fail("unsealed executable was yielded") + self.assertEqual(len(captured), 1) + with self.assertRaises(OSError): + os.fstat(captured[0]) + + def test_sealed_memfd_executes_a_real_native_binary(self) -> None: + payload = Path("/usr/bin/true").read_bytes() + layout = [locked_member("tool", payload)] + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) / "installed" + directory.mkdir(mode=0o700) + tool = directory / "tool" + tool.write_bytes(payload) + tool.chmod(0o755) + child = os.fork() + if child == 0: + try: + with open_validated_executable(directory, layout, "tool") as source: + with open_sealed_executable(source, layout[0]) as executable: + os.execve(executable, ["tool"], {"PATH": "/usr/bin"}) + finally: + os._exit(127) + _, status = os.waitpid(child, 0) + self.assertEqual(os.waitstatus_to_exitcode(status), 0) + + +if __name__ == "__main__": + unittest.main() From df3a56bb36106d8c4d391be9e47d2e062b9ce41e Mon Sep 17 00:00:00 2001 From: 0cwa Date: Mon, 20 Jul 2026 14:57:25 +0200 Subject: [PATCH 15/18] refactor(deps): route tools through locked bootstrap Batch enabled executable acquisition before legacy downloads, reject legacy acquisition paths, clear stale disabled bindings, and route PixeneOS-owned avbroot calls through sealed descriptor execution. Co-Authored-By: ruflo-bot --- src/declarations.sh | 2 - src/fetcher.sh | 14 +- src/util_functions.sh | 179 +++++++++++--- src/verifier.sh | 5 + tests/executable_tool_routing_test.sh | 342 ++++++++++++++++++++++++++ 5 files changed, 491 insertions(+), 51 deletions(-) create mode 100644 tests/executable_tool_routing_test.sh diff --git a/src/declarations.sh b/src/declarations.sh index 5c39a8c7..6cedfc63 100755 --- a/src/declarations.sh +++ b/src/declarations.sh @@ -37,9 +37,7 @@ PIXENEOS_RELEASE_BASE_URL="${PIXENEOS_RELEASE_BASE_URL:-}" PIXENEOS_AVBROOT_SETUP_SOURCE="${PIXENEOS_AVBROOT_SETUP_SOURCE:-}" # Application version variables -VERSION[AFSR]="${VERSION[AFSR]:-1.0.4}" VERSION[ALTERINSTALLER]="${VERSION[ALTERINSTALLER]:-2.4}" -VERSION[AVBROOT]="${VERSION[AVBROOT]:-3.31.0}" VERSION[AVBROOT_SETUP]="09d32371829fb3b34455edbd2fee58fd84db613c" # Commit hash VERSION[BCR]="${VERSION[BCR]:-3.4}" VERSION[CUSTOTA]="${VERSION[CUSTOTA]:-6.2}" diff --git a/src/fetcher.sh b/src/fetcher.sh index ef7e0899..72771da8 100755 --- a/src/fetcher.sh +++ b/src/fetcher.sh @@ -45,6 +45,11 @@ function get() { echo "Downloading \`${filename}\`..." + if [[ "${filename}" == "afsr" || "${filename}" == "avbroot" || "${filename}" == "custota-tool" ]]; then + echo "Error: executable tools require immutable-lock bootstrap verification." >&2 + return 1 + fi + # `my-avbroot-setup` is a special case as it is a git repository if [[ "${filename}" == "my-avbroot-setup" ]]; then git clone "${url}" "${WORKDIR}/tools/${filename}" && git -C "${WORKDIR}/tools/${filename}" checkout "${VERSION[AVBROOT_SETUP]}" @@ -65,15 +70,6 @@ function get() { curl -sLf "${signature_url}" --output "${WORKDIR}/signatures/${filename}.zip.sig" fi - # afsr, avbroot and custota-tool are binaries that need to be extracted and granted permissions - if [[ "${filename}" == "afsr" || "${filename}" == "avbroot" || "${filename}" == "custota-tool" ]]; then - echo -e "Extracting and granting permissions for \`${filename}\`..." - echo N | unzip -q -o "${WORKDIR}/modules/${filename}.zip" -d "${WORKDIR}/tools/${filename}" - chmod +x "${WORKDIR}/tools/${filename}/${filename}" - - echo -e "Cleaning up..." - rm "${WORKDIR}/modules/${filename}.zip" - fi fi fi echo -e "\`${filename}\` downloaded." diff --git a/src/util_functions.sh b/src/util_functions.sh index b9d8bf19..14360cb9 100755 --- a/src/util_functions.sh +++ b/src/util_functions.sh @@ -37,8 +37,31 @@ function check_and_download_dependencies() { # Convert the space-separated string back into an array IFS=' ' read -r -a tools_array <<<"${tools}" + local -a executable_tools=() + local tool flag for tool in "${tools_array[@]}"; do - local flag=$(flag_check "${tool}") + flag="$(flag_check "${tool}")" + + if [[ "${tool}" == "afsr" || "${tool}" == "avbroot" || "${tool}" == "custota-tool" ]]; then + if [[ "${flag}" == "true" ]]; then + executable_tools+=("${tool}") + fi + continue + fi + + done + + # Authenticate the complete executable set before extracting any executable. + if ((${#executable_tools[@]})); then + bootstrap_executable_tools "${executable_tools[@]}" || return 1 + fi + + for tool in "${tools_array[@]}"; do + flag="$(flag_check "${tool}")" + + if [[ "${tool}" == "afsr" || "${tool}" == "avbroot" || "${tool}" == "custota-tool" ]]; then + continue + fi if [[ "${flag}" == 'false' ]]; then echo -e "\`${tool}\` is **NOT** enabled in the configuration.\nSkipping...\n" @@ -79,6 +102,34 @@ function check_and_download_dependencies() { fi } +function bootstrap_executable_tools() { + local -a selected=("$@") + local report="${WORKDIR}/reports/executable-tools.json" + + python3 src/bootstrap_executable_tools.py \ + --workdir "${WORKDIR}" \ + install \ + --report "${report}" \ + "${selected[@]}" +} + +function resolve_executable_tool() { + local tool="${1}" + + python3 src/bootstrap_executable_tools.py \ + --workdir "${WORKDIR}" \ + resolve "${tool}" +} + +function run_executable_tool() { + local tool="${1}" + shift + + python3 src/bootstrap_executable_tools.py \ + --workdir "${WORKDIR}" \ + run "${tool}" -- "$@" +} + # Function to check the flag status # If flag for a tool is disabled, it is not downloaded function flag_check() { @@ -252,16 +303,18 @@ function generate_keys() { "$(dirname "${KEYS[PKMD]}")" # Generate the AVB and OTA signing keys - avbroot key generate-key -o "${KEYS[AVB]}" - avbroot key generate-key -o "${KEYS[OTA]}" + run_executable_tool avbroot key generate-key -o "${KEYS[AVB]}" || return 1 + run_executable_tool avbroot key generate-key -o "${KEYS[OTA]}" || return 1 # Convert the public key portion of the AVB signing key to the AVB public key metadata format # This is the format that the bootloader requires when setting the custom root of trust - avbroot key extract-avb -k "${KEYS[AVB]}" -o "${KEYS[PKMD]}" + run_executable_tool avbroot key extract-avb \ + -k "${KEYS[AVB]}" -o "${KEYS[PKMD]}" || return 1 # Generate a self-signed certificate for the OTA signing key # This is used by recovery to verify OTA updates when sideloading - avbroot key generate-cert -k "${KEYS[OTA]}" -o "${KEYS[CERT_OTA]}" + run_executable_tool avbroot key generate-cert \ + -k "${KEYS[OTA]}" -o "${KEYS[CERT_OTA]}" || return 1 # Convert the keys to base64 which can be used in CI/CD pipeline environment base64_encode @@ -444,23 +497,53 @@ PY # Function to setup the environment variables and paths for patching the OTA function env_setup() { - # Set up `my-avbroot-setup` environment - my_avbroot_setup - - # Paths - local avbroot="${WORKDIR}/tools/avbroot" - local afsr="${WORKDIR}/tools/afsr" - local custota_tool="${WORKDIR}/tools/custota-tool" local my_avbroot_setup="${WORKDIR}/tools/my-avbroot-setup" local requirements_file="${my_avbroot_setup}/requirements.txt" - - # Add the paths to the PATH environment variable just so that the script can find them - if ! command -v avbroot &>/dev/null && ! command -v afsr &>/dev/null && ! command -v custota-tool &>/dev/null; then - export PATH="$(realpath ${afsr}):$(realpath ${avbroot}):$(realpath ${custota_tool}):$PATH" + local tool flag executable variable path_prefix + local -a selected_tools=() + local -a resolved_executables=() + local -a executable_directories=() + + # Restore the caller PATH from the last successful setup before resolving a + # new selection. Only the exact prefix injected by this function is removed. + unset PIXENEOS_AVBROOT_BIN PIXENEOS_AFSR_BIN PIXENEOS_CUSTOTA_TOOL_BIN + if [[ -n "${PIXENEOS_EXECUTABLE_PATH_PREFIX:-}" ]]; then + if [[ "${PATH}" == "${PIXENEOS_EXECUTABLE_PATH_PREFIX}" ]]; then + PATH="" + elif [[ "${PATH}" == "${PIXENEOS_EXECUTABLE_PATH_PREFIX}:"* ]]; then + PATH="${PATH#"${PIXENEOS_EXECUTABLE_PATH_PREFIX}:"}" + elif [[ -n "${PIXENEOS_EXECUTABLE_BASE_PATH+x}" ]]; then + PATH="${PIXENEOS_EXECUTABLE_BASE_PATH}" + export PATH + unset PIXENEOS_EXECUTABLE_PATH_PREFIX PIXENEOS_EXECUTABLE_BASE_PATH + echo "Error: executable PATH prefix changed after setup." >&2 + return 1 + else + unset PIXENEOS_EXECUTABLE_PATH_PREFIX + echo "Error: executable PATH tracking is incomplete." >&2 + return 1 + fi + export PATH fi + unset PIXENEOS_EXECUTABLE_PATH_PREFIX PIXENEOS_EXECUTABLE_BASE_PATH + + # Resolve the complete enabled set before modifying helper source, activating + # an environment, or exposing any executable binding. + for tool in avbroot afsr custota-tool; do + flag="$(flag_check "${tool}")" + if [[ "${flag}" != "true" ]]; then + continue + fi + executable="$(resolve_executable_tool "${tool}")" || return 1 + selected_tools+=("${tool}") + resolved_executables+=("${executable}") + done + + # Set up `my-avbroot-setup` only after every enabled executable resolved. + my_avbroot_setup || return 1 # Enabled python virtual environment - enable_venv + enable_venv || return 1 # Install required Python packages if [[ -f "${requirements_file}" ]]; then @@ -475,11 +558,34 @@ function env_setup() { if [[ "${missing_packages}" == "true" ]]; then echo -e "Installing required Python packages from requirements.txt..." - pip3 install -r "${requirements_file}" + pip3 install -r "${requirements_file}" || return 1 fi else echo -e "Warning: requirements.txt not found at ${requirements_file}" fi + + local index + for index in "${!selected_tools[@]}"; do + tool="${selected_tools[${index}]}" + executable="${resolved_executables[${index}]}" + case "${tool}" in + avbroot) variable="PIXENEOS_AVBROOT_BIN" ;; + afsr) variable="PIXENEOS_AFSR_BIN" ;; + custota-tool) variable="PIXENEOS_CUSTOTA_TOOL_BIN" ;; + esac + printf -v "${variable}" '%s' "${executable}" + export "${variable}" + executable_directories+=("$(dirname -- "${executable}")") + done + + # The pinned helper currently resolves these names through PATH. Track the + # exact injected prefix so a later setup can restore the caller's base PATH. + if ((${#executable_directories[@]})); then + path_prefix="$(IFS=:; echo "${executable_directories[*]}")" + PIXENEOS_EXECUTABLE_BASE_PATH="${PATH}" + PIXENEOS_EXECUTABLE_PATH_PREFIX="${path_prefix}" + export PATH="${path_prefix}:${PATH}" + fi } # Function to enable the python virtual environment @@ -533,26 +639,15 @@ function url_constructor() { if [[ "${repository}" == "my-avbroot-setup" ]]; then URL="${PIXENEOS_AVBROOT_SETUP_SOURCE:-${DOMAIN}/0cwa/${repository}}" SIGNATURE_URL="" + elif [[ "${repository}" == "afsr" || "${repository}" == "avbroot" || "${repository}" == "custota-tool" ]]; then + echo "Error: executable tools must be acquired from the immutable lock." >&2 + return 1 else - # Afsr, avbroot, and custota-tool are binaries and are platform dependent. Modules are zipped files. - if [[ "${repository}" == "afsr" || "${repository}" == "avbroot" || "${repository}" == "custota-tool" ]]; then - local suffix="${ARCH}" - else - local suffix="release" - fi + local suffix="release" - # Custota is a special case - # Custota is a module and Custota-Tool is a binary - # Both reside in same repository - if [[ "${repository}" == "custota-tool" ]]; then - local download_page="${DOMAIN}/${user}/Custota/releases/download" - local version="v${VERSION[CUSTOTA]}" - local application="${repository}-${VERSION[CUSTOTA]}-${suffix}.zip" - else - local download_page="${DOMAIN}/${user}/${repository}/releases/download" - local version="v${VERSION[${repository_upper_case}]}" - local application="${repository}-${VERSION[${repository_upper_case}]}-${suffix}.zip" - fi + local download_page="${DOMAIN}/${user}/${repository}/releases/download" + local version="v${VERSION[${repository_upper_case}]}" + local application="${repository}-${VERSION[${repository_upper_case}]}-${suffix}.zip" URL="${download_page}/${version}/${application}" SIGNATURE_URL="${download_page}/${version}/${application}.sig" @@ -603,21 +698,24 @@ function extract_official_keys() { # OTA: Extract META-INF/com/android/otacert from the OTA. # (Or from otacerts.zip inside system.img or vendor_boot.img. All 3 files are identical.) local ota_zip="${WORKDIR}/${GRAPHENEOS[OTA_TARGET]}.zip" + local avb_info # Extract OTA - avbroot ota extract \ + run_executable_tool avbroot ota extract \ --input "${ota_zip}" \ --directory "${WORKDIR}/extracted/extracts" \ - --all + --all || return 1 # Extract vbmeta.img # To verify, execute sha256sum avb_pkmd.bin in terminal # compare the output with base16-encoded verified boot key fingerprints # mentioned at https://grapheneos.org/articles/attestation-compatibility-guide for the respective device - avbroot avb info -i "${WORKDIR}/extracted/extracts/vbmeta.img" | + avb_info="$(run_executable_tool avbroot avb info \ + -i "${WORKDIR}/extracted/extracts/vbmeta.img")" || return 1 + printf '%s\n' "${avb_info}" | grep 'public_key' | sed -n 's/.*public_key: "\(.*\)".*/\1/p' | - tr -d '[:space:]' | xxd -r -p >"${WORKDIR}/extracted/avb_pkmd.bin" + tr -d '[:space:]' | xxd -r -p >"${WORKDIR}/extracted/avb_pkmd.bin" || return 1 # Extract META-INF/com/android/otacert from OTA or otacerts.zip from either vendor_boot.img or system.img unzip "${ota_zip}" -d "${WORKDIR}/extracted/ota" @@ -641,6 +739,7 @@ function make_directories() { "${WORKDIR}/modules" \ "${WORKDIR}/signatures" \ "${WORKDIR}/tools" + chmod 0700 -- "${WORKDIR}" "${WORKDIR}/.keys" } function generate_ota_info() { diff --git a/src/verifier.sh b/src/verifier.sh index eadd8a77..6a5dff8d 100755 --- a/src/verifier.sh +++ b/src/verifier.sh @@ -93,6 +93,11 @@ function verify_downloads() { local tool="${1}" local tool_path="" + if [[ "${tool}" == "afsr" || "${tool}" == "avbroot" || "${tool}" == "custota-tool" ]]; then + echo "Error: executable tools require immutable-lock bootstrap verification." >&2 + return 1 + fi + echo "Verifying \`${tool}\`..." if [[ -f "${WORKDIR}/modules/${tool}.zip" ]] && [ "${tool}" != "magisk" ]; then diff --git a/tests/executable_tool_routing_test.sh b/tests/executable_tool_routing_test.sh new file mode 100644 index 00000000..ea495760 --- /dev/null +++ b/tests/executable_tool_routing_test.sh @@ -0,0 +1,342 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 PixeneOS contributors + +set -eo pipefail + +source src/util_functions.sh + +set -u + +TEST_ROOT="$(mktemp -d)" +trap 'rm -rf "${TEST_ROOT}"' EXIT + +fail() { + echo "$*" >&2 + exit 1 +} + +assert_file_equals() { + local expected="${1}" + local path="${2}" + local actual="" + + if [[ -f "${path}" ]]; then + actual="$(<"${path}")" + fi + [[ "${actual}" == "${expected}" ]] || + fail "Expected ${path} to contain '${expected}', got '${actual}'" +} + +test_executables_are_bootstrapped_once_in_supported_order() ( + local root="${TEST_ROOT}/batch" + WORKDIR="${root}/work" + ADDITIONALS[RETRY]="false" + ADDITIONALS[ROOT]="false" + + make_directories() { mkdir -p "${WORKDIR}"; } + supported_tools() { + echo "avbroot disabled-legacy afsr custota-tool enabled-legacy" + } + flag_check() { + case "${1}" in + disabled-legacy) echo false ;; + *) echo true ;; + esac + } + bootstrap_executable_tools() { printf '%s\n' "$*" >"${root}/bootstrap"; } + download_dependencies() { printf '%s\n' "${1}" >>"${root}/downloads"; } + verify_downloads() { printf '%s\n' "${1}" >>"${root}/verified"; } + + mkdir -p "${root}" + check_and_download_dependencies >/dev/null + + assert_file_equals "avbroot afsr custota-tool" "${root}/bootstrap" + assert_file_equals "enabled-legacy" "${root}/downloads" + assert_file_equals "enabled-legacy" "${root}/verified" +) + +test_disabled_executables_are_not_bootstrapped() ( + local root="${TEST_ROOT}/disabled-bootstrap" + WORKDIR="${root}/work" + ADDITIONALS[RETRY]="false" + ADDITIONALS[ROOT]="false" + + make_directories() { mkdir -p "${WORKDIR}"; } + supported_tools() { echo "avbroot afsr custota-tool"; } + flag_check() { echo false; } + bootstrap_executable_tools() { touch "${root}/bootstrap"; } + download_dependencies() { touch "${root}/legacy-download"; } + verify_downloads() { touch "${root}/legacy-verify"; } + + mkdir -p "${root}" + check_and_download_dependencies >/dev/null + + [[ ! -e "${root}/bootstrap" ]] || fail "Disabled executable was bootstrapped" + [[ ! -e "${root}/legacy-download" ]] || + fail "Disabled executable reached legacy acquisition" + [[ ! -e "${root}/legacy-verify" ]] || + fail "Disabled executable reached legacy verification" +) + +test_bootstrap_failure_stops_before_legacy_acquisition() ( + local root="${TEST_ROOT}/bootstrap-failure" + local result + WORKDIR="${root}/work" + ADDITIONALS[RETRY]="false" + ADDITIONALS[ROOT]="false" + + make_directories() { mkdir -p "${WORKDIR}"; } + supported_tools() { echo "avbroot enabled-legacy"; } + flag_check() { echo true; } + bootstrap_executable_tools() { return 42; } + download_dependencies() { touch "${root}/legacy-download"; } + verify_downloads() { touch "${root}/legacy-verify"; } + + mkdir -p "${root}" + set +e + check_and_download_dependencies >/dev/null + result=$? + set -e + + [[ "${result}" -ne 0 ]] || fail "Bootstrap failure was reported as success" + [[ ! -e "${root}/legacy-download" ]] || + fail "Legacy acquisition ran after bootstrap failure" + [[ ! -e "${root}/legacy-verify" ]] || + fail "Legacy verification ran after bootstrap failure" +) + +test_env_setup_resolves_only_enabled_executables() ( + local root="${TEST_ROOT}/enabled-env" + local old_path="/usr/local/bin:/usr/bin" + local avb_digest="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + local custota_digest="cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + WORKDIR="${root}/work" + PATH="${old_path}" + ADDITIONALS[AVBROOT]="true" + ADDITIONALS[AFSR]="false" + ADDITIONALS[CUSTOTA_TOOL]="true" + PIXENEOS_AFSR_BIN="stale-disabled-value" + + my_avbroot_setup() { :; } + enable_venv() { :; } + resolve_executable_tool() { + printf '%s\n' "${1}" >>"${root}/resolved" + case "${1}" in + avbroot) echo "/opt/pixene/by-sha256/${avb_digest}/avbroot" ;; + custota-tool) echo "/opt/pixene/by-sha256/${custota_digest}/custota-tool" ;; + *) return 99 ;; + esac + } + + mkdir -p "${WORKDIR}/tools/my-avbroot-setup" + env_setup >/dev/null + + assert_file_equals $'avbroot\ncustota-tool' "${root}/resolved" + [[ "${PIXENEOS_AVBROOT_BIN}" == "/opt/pixene/by-sha256/${avb_digest}/avbroot" ]] || + fail "Enabled avbroot was not exported" + [[ "${PIXENEOS_CUSTOTA_TOOL_BIN}" == "/opt/pixene/by-sha256/${custota_digest}/custota-tool" ]] || + fail "Enabled custota-tool was not exported" + [[ -z "${PIXENEOS_AFSR_BIN+x}" ]] || fail "Disabled afsr export was retained" + [[ "${PATH}" == "/opt/pixene/by-sha256/${avb_digest}:/opt/pixene/by-sha256/${custota_digest}:${old_path}" ]] || + fail "PATH does not contain only enabled digest-bound directories: ${PATH}" +) + +test_env_setup_resolve_failure_is_transactional() ( + local root="${TEST_ROOT}/resolve-failure" + local old_path="/caller/bin:/usr/bin" + local old_prefix="/old/pixene/avbroot:/old/pixene/afsr" + local result + WORKDIR="${root}/work" + PATH="${old_prefix}:${old_path}" + ADDITIONALS[AVBROOT]="true" + ADDITIONALS[AFSR]="true" + ADDITIONALS[CUSTOTA_TOOL]="false" + PIXENEOS_AVBROOT_BIN="/old/pixene/avbroot/avbroot" + PIXENEOS_AFSR_BIN="/old/pixene/afsr/afsr" + PIXENEOS_CUSTOTA_TOOL_BIN="stale-disabled-value" + PIXENEOS_EXECUTABLE_PATH_PREFIX="${old_prefix}" + PIXENEOS_EXECUTABLE_BASE_PATH="${old_path}" + + my_avbroot_setup() { touch "${root}/helper-mutated"; } + enable_venv() { touch "${root}/venv-enabled"; } + resolve_executable_tool() { + case "${1}" in + avbroot) echo "/opt/pixene/by-sha256/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/avbroot" ;; + *) return 55 ;; + esac + } + + mkdir -p "${WORKDIR}/tools/my-avbroot-setup" + set +e + env_setup >/dev/null + result=$? + set -e + + [[ "${result}" -ne 0 ]] || fail "Resolve failure was reported as success" + [[ "${PATH}" == "${old_path}" ]] || fail "Stale injected PATH survived resolve failure" + [[ -z "${PIXENEOS_AVBROOT_BIN+x}" ]] || + fail "Stale avbroot export survived resolve failure" + [[ -z "${PIXENEOS_AFSR_BIN+x}" ]] || fail "Stale afsr export survived resolve failure" + [[ -z "${PIXENEOS_CUSTOTA_TOOL_BIN+x}" ]] || + fail "Stale custota-tool export survived resolve failure" + [[ -z "${PIXENEOS_EXECUTABLE_PATH_PREFIX+x}" ]] || + fail "Stale injected PATH tracking survived resolve failure" + [[ ! -e "${root}/helper-mutated" ]] || + fail "Helper source was mutated before all executable resolution succeeded" + [[ ! -e "${root}/venv-enabled" ]] || fail "Environment setup continued after resolve failure" +) + +test_repeated_env_setup_removes_disabled_tool_bindings() ( + local root="${TEST_ROOT}/repeated-env" + local old_path="/caller/bin:/usr/bin" + local avb_digest="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + local afsr_digest="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + WORKDIR="${root}/work" + PATH="${old_path}" + ADDITIONALS[AVBROOT]="true" + ADDITIONALS[AFSR]="true" + ADDITIONALS[CUSTOTA_TOOL]="false" + + my_avbroot_setup() { :; } + enable_venv() { :; } + resolve_executable_tool() { + case "${1}" in + avbroot) echo "/opt/pixene/by-sha256/${avb_digest}/avbroot" ;; + afsr) echo "/opt/pixene/by-sha256/${afsr_digest}/afsr" ;; + *) return 99 ;; + esac + } + + mkdir -p "${WORKDIR}/tools/my-avbroot-setup" + env_setup >/dev/null + [[ "${PATH}" == "/opt/pixene/by-sha256/${avb_digest}:/opt/pixene/by-sha256/${afsr_digest}:${old_path}" ]] || + fail "Initial executable PATH was not constructed as expected" + + PATH="${PATH}:/caller/added-after-setup" + ADDITIONALS[AFSR]="false" + env_setup >/dev/null + + [[ -z "${PIXENEOS_AFSR_BIN+x}" ]] || fail "Disabled afsr export survived repeated setup" + [[ "${PATH}" == "/opt/pixene/by-sha256/${avb_digest}:${old_path}:/caller/added-after-setup" ]] || + fail "Repeated setup retained or duplicated an injected tool directory: ${PATH}" + [[ "${PIXENEOS_EXECUTABLE_BASE_PATH}" == "${old_path}:/caller/added-after-setup" ]] || + fail "Caller base PATH was not preserved" +) + +test_runner_constructs_exact_fd_bound_command() ( + local root="${TEST_ROOT}/runner-command" + WORKDIR="${root}/work" + + mkdir -p "${root}" + python3() { printf '%s\n' "$*" >"${root}/python-args"; } + run_executable_tool avbroot key generate-key -o "/output/key" + + assert_file_equals \ + "src/bootstrap_executable_tools.py --workdir ${WORKDIR} run avbroot -- key generate-key -o /output/key" \ + "${root}/python-args" +) + +test_runner_failure_prevents_generate_keys_fallback_execution() ( + local root="${TEST_ROOT}/generate-keys" + local result + WORKDIR="${root}/work" + PATH="${root}/bin:/usr/bin" + + mkdir -p "${root}/bin" + printf '#!/usr/bin/env bash\ntouch %q\n' "${root}/executed" >"${root}/bin/avbroot" + chmod 0755 "${root}/bin/avbroot" + run_executable_tool() { return 66; } + base64_encode() { touch "${root}/base64-ran"; } + + set +e + generate_keys >/dev/null 2>&1 + result=$? + set -e + + [[ "${result}" -ne 0 ]] || fail "Runner failure was reported as success" + [[ ! -e "${root}/executed" ]] || fail "PATH fallback ran after runner failure" + [[ ! -e "${root}/base64-ran" ]] || fail "Key processing continued after runner failure" +) + +test_pixene_avbroot_calls_preserve_exact_arguments() ( + local root="${TEST_ROOT}/runner-arguments" + WORKDIR="${root}/work" + GRAPHENEOS[OTA_TARGET]="official" + KEYS[AVB]="${root}/keys/avb.key" + KEYS[OTA]="${root}/keys/ota.key" + KEYS[PKMD]="${root}/keys/avb_pkmd.bin" + KEYS[CERT_OTA]="${root}/keys/ota.crt" + + run_executable_tool() { + printf '%s\n' "$*" >>"${root}/runner-args" + if [[ "${1} ${2} ${3}" == "avbroot avb info" ]]; then + echo 'public_key: "aa"' + fi + } + base64_encode() { :; } + unzip() { :; } + + mkdir -p "${WORKDIR}/extracted/extracts" + generate_keys >/dev/null + extract_official_keys >/dev/null + + assert_file_equals \ + "$(printf '%s\n' \ + "avbroot key generate-key -o ${KEYS[AVB]}" \ + "avbroot key generate-key -o ${KEYS[OTA]}" \ + "avbroot key extract-avb -k ${KEYS[AVB]} -o ${KEYS[PKMD]}" \ + "avbroot key generate-cert -k ${KEYS[OTA]} -o ${KEYS[CERT_OTA]}" \ + "avbroot ota extract --input ${WORKDIR}/official.zip --directory ${WORKDIR}/extracted/extracts --all" \ + "avbroot avb info -i ${WORKDIR}/extracted/extracts/vbmeta.img")" \ + "${root}/runner-args" +) + +test_legacy_acquisition_rejects_locked_executables() ( + local root="${TEST_ROOT}/legacy-guard" + WORKDIR="${root}/work" + INTERACTIVE_MODE="false" + get() { touch "${root}/legacy-get"; } + + mkdir -p "${root}" + local tool + for tool in avbroot afsr custota-tool; do + if url_constructor "${tool}" false >/dev/null 2>&1; then + fail "Legacy URL construction accepted ${tool}" + fi + done + [[ ! -e "${root}/legacy-get" ]] || fail "Legacy get ran for locked executable" +) + +test_direct_legacy_get_and_verify_reject_locked_executables() ( + local root="${TEST_ROOT}/direct-legacy-guards" + WORKDIR="${root}/work" + + mkdir -p "${WORKDIR}/modules" "${WORKDIR}/signatures" "${WORKDIR}/tools" + local tool + for tool in avbroot afsr custota-tool; do + if get "${tool}" "https://fixtures.invalid/${tool}.zip" \ + "https://fixtures.invalid/${tool}.zip.sig" >/dev/null 2>&1; then + fail "Direct legacy get accepted ${tool}" + fi + if verify_downloads "${tool}" >/dev/null 2>&1; then + fail "Legacy verification accepted ${tool}" + fi + done + [[ -z "$(find "${WORKDIR}" -type f -print -quit)" ]] || + fail "Direct legacy guards created an executable artifact" +) + +test_executables_are_bootstrapped_once_in_supported_order +test_disabled_executables_are_not_bootstrapped +test_bootstrap_failure_stops_before_legacy_acquisition +test_env_setup_resolves_only_enabled_executables +test_env_setup_resolve_failure_is_transactional +test_repeated_env_setup_removes_disabled_tool_bindings +test_runner_constructs_exact_fd_bound_command +test_runner_failure_prevents_generate_keys_fallback_execution +test_pixene_avbroot_calls_preserve_exact_arguments +test_legacy_acquisition_rejects_locked_executables +test_direct_legacy_get_and_verify_reject_locked_executables + +echo "executable tool routing tests passed" From 04fb6970b63e8a8ffe2b6756e7373ef9a9fff5da Mon Sep 17 00:00:00 2001 From: 0cwa Date: Mon, 20 Jul 2026 14:57:35 +0200 Subject: [PATCH 16/18] docs(bootstrap): document runtime trust boundaries Describe sealed direct execution, compatibility-only helper PATH resolution, deterministic reports, and the remaining trusted-runner gate before real OTA integration. Co-Authored-By: ruflo-bot --- docs/executable-tool-trust.md | 65 ++++++++++++++++++++++++++++++----- 1 file changed, 56 insertions(+), 9 deletions(-) diff --git a/docs/executable-tool-trust.md b/docs/executable-tool-trust.md index 2085486d..e80339d5 100644 --- a/docs/executable-tool-trust.md +++ b/docs/executable-tool-trust.md @@ -116,15 +116,62 @@ identities, and wrong namespaces. Re-verify all retained release artifacts after rotation; do not infer that a new key authenticates old assets without valid new signatures or an authoritative cross-binding. -## Explicit non-authorization - -This tranche does **not** integrate the lock into PixeneOS acquisition, cache -reuse, extraction, permission changes, or execution. The presence of a lock -entry, valid repository data, successful fixture validation, or prior manual -inspection does not authorize executing an archive member. Until a later, -independently reviewed integration enforces digest and signature verification on -the actual downloaded inode before any extraction, `chmod`, or execution, the -existing executable-tool acquisition path remains untrusted. +## Runtime bootstrap enforcement + +`src/bootstrap_executable_tools.py` consumes this lock directly. The shell +runtime does not reconstruct executable release versions, asset URLs, or +layouts. It selects enabled tool IDs and submits the entire set in one batch. +Every selected archive must pass its locked byte size, SHA-256, detached +OpenSSH signature, exact hostile-archive inspection, and full member digest +check before extraction of any selected archive begins. + +Downloads use bounded streaming into exclusive, no-follow files in a private +mode-`0700` transaction directory. Verified bytes are fsynced and atomically +published under `bootstrap-cache/objects`; a canonical receipt binds the +archive digest to its verified signature digest. Cache hits repeat size, +digest, signature, and archive inspection. A missing, corrupt, linked, or +noncanonical cache entry fails closed. + +Members are extracted without `unzip` into private transaction directories. +The extractor accepts only the exact locked top-level regular-file layout and +rejects traversal, absolute paths, backslashes, aliases, duplicates, links, +special files, directories, extras, encryption, unsupported ZIP flags or +compression, overlapping compressed ranges, and excessive expansion. It +rechecks extracted types, modes, sizes, and digests before atomically publishing +the directory at `tools/by-sha256/`. + +Existing digest-addressed installations are fully revalidated. Legacy +`tools/` directories are never trusted as an installation bypass. Direct +PixeneOS invocations use the runtime `run` command, which opens the exact +digest-addressed installation through a held directory descriptor, revalidates +the complete locked layout, copies the verified executable bytes into an +anonymous file descriptor, sets the locked mode, seals the snapshot against +content changes, and rechecks its digest and mode after sealing. It executes +that sealed descriptor without resolving the executable pathname again. + +The `resolve` command and digest-addressed executable paths exist only for +compatibility with the pinned helper. A successful resolution does not itself +authorize execution. The helper still resolves `avbroot`, `afsr`, and +`custota-tool` through `PATH`; PixeneOS places only enabled digest-addressed +directories at the front of that path, but the helper's later pathname lookup +remains an open execution boundary. Close it with the planned trusted-prefix +`ToolRunner` integration before treating helper execution as inode-bound. + +The canonical JSON report contains stable tool identity, version, architecture, +archive and member sizes/digests, and signer verification results. It omits +URLs, timestamps, cache-hit state, temporary names, and all cache/report paths. +The report is written atomically only after the complete selected transaction +succeeds. + +The offline lock validator by itself still does **not** authorize extraction, +permission changes, or execution. A successful runtime transaction authorizes +authenticated installation, and the `run` command authorizes direct PixeneOS +execution only through its post-seal-verified anonymous file descriptor. It +does not authorize the helper's compatibility PATH execution. Run acquisition +through `check_and_download_dependencies`; do not call the legacy downloader +for these three executable tools. Real OTA integration remains blocked until +the helper uses the trusted runner and the other documented host-code +supply-chain gates are closed. [signing-guide]: https://github.com/chenxiaolong/chenxiaolong/blob/master/VERIFY_SSH_SIGNATURES.md [afsr-release]: https://github.com/chenxiaolong/afsr/releases/tag/v1.0.4 From 24df215dfdec96d1c2a6a7b08da23815324d8590 Mon Sep 17 00:00:00 2001 From: 0cwa Date: Sat, 8 Aug 2026 17:07:46 +0200 Subject: [PATCH 17/18] fix(build): preflight pinned helper contract --- src/declarations.sh | 2 +- src/util_functions.sh | 43 ++++++++++-- tests/helper_preflight_test.sh | 125 +++++++++++++++++++++++++++++++++ 3 files changed, 162 insertions(+), 8 deletions(-) create mode 100755 tests/helper_preflight_test.sh diff --git a/src/declarations.sh b/src/declarations.sh index 6cedfc63..a6fd9920 100755 --- a/src/declarations.sh +++ b/src/declarations.sh @@ -38,7 +38,7 @@ PIXENEOS_AVBROOT_SETUP_SOURCE="${PIXENEOS_AVBROOT_SETUP_SOURCE:-}" # Application version variables VERSION[ALTERINSTALLER]="${VERSION[ALTERINSTALLER]:-2.4}" -VERSION[AVBROOT_SETUP]="09d32371829fb3b34455edbd2fee58fd84db613c" # Commit hash +VERSION[AVBROOT_SETUP]="a14c242a89abb1a13b8c7474dd8235ee75fd31d6" # Proven compatible helper commit VERSION[BCR]="${VERSION[BCR]:-3.4}" VERSION[CUSTOTA]="${VERSION[CUSTOTA]:-6.2}" VERSION[GRAPHENEOS]="${VERSION[GRAPHENEOS]:-}" diff --git a/src/util_functions.sh b/src/util_functions.sh index 14360cb9..d2dbb4d4 100755 --- a/src/util_functions.sh +++ b/src/util_functions.sh @@ -248,6 +248,8 @@ function create_and_make_release() { check_and_download_dependencies fi + helper_repository_preflight || return 1 + # Calls the download_ota function to download the OTA if not found download_ota # Calls the create_ota function to create the OTA @@ -260,7 +262,9 @@ function create_ota() { # Generate output file names generate_ota_info # Setup environment variables and paths - env_setup + env_setup || return 1 + helper_contract_preflight || return 1 + my_avbroot_setup || return 1 # Patch OTA with avbroot and afsr by leveraging my-avbroot-setup patch_ota } @@ -495,6 +499,34 @@ setup_script.write_text(text.replace(old, new, 1)) PY } +# Verify the fetched helper is the pinned revision before downloading an OTA. +function helper_repository_preflight() { + local helper_dir="${WORKDIR}/tools/my-avbroot-setup" + local expected="${VERSION[AVBROOT_SETUP]}" + local actual + + actual="$(git -C "${helper_dir}" rev-parse --verify HEAD 2>/dev/null)" || { + echo "Error: helper repository is missing or has no commit: ${helper_dir}" >&2 + return 1 + } + if [[ "${actual}" != "${expected}" ]]; then + echo "Error: helper contract mismatch: expected ${expected}, got ${actual}" >&2 + return 1 + fi +} + +# Verify the pinned helper can run after its environment is ready and before +# rewriting helper source. +function helper_contract_preflight() { + local helper_dir="${WORKDIR}/tools/my-avbroot-setup" + + helper_repository_preflight || return 1 + if ! python "${helper_dir}/patch.py" --help >/dev/null 2>&1; then + echo "Error: helper patch.py contract smoke check failed" >&2 + return 1 + fi +} + # Function to setup the environment variables and paths for patching the OTA function env_setup() { local my_avbroot_setup="${WORKDIR}/tools/my-avbroot-setup" @@ -527,8 +559,8 @@ function env_setup() { fi unset PIXENEOS_EXECUTABLE_PATH_PREFIX PIXENEOS_EXECUTABLE_BASE_PATH - # Resolve the complete enabled set before modifying helper source, activating - # an environment, or exposing any executable binding. + # Resolve the complete enabled set before activating an environment or + # exposing any executable binding. for tool in avbroot afsr custota-tool; do flag="$(flag_check "${tool}")" if [[ "${flag}" != "true" ]]; then @@ -539,9 +571,6 @@ function env_setup() { resolved_executables+=("${executable}") done - # Set up `my-avbroot-setup` only after every enabled executable resolved. - my_avbroot_setup || return 1 - # Enabled python virtual environment enable_venv || return 1 @@ -739,7 +768,7 @@ function make_directories() { "${WORKDIR}/modules" \ "${WORKDIR}/signatures" \ "${WORKDIR}/tools" - chmod 0700 -- "${WORKDIR}" "${WORKDIR}/.keys" + chmod 0700 -- "${WORKDIR}" "${WORKDIR}/.keys" "${WORKDIR}/tools" } function generate_ota_info() { diff --git a/tests/helper_preflight_test.sh b/tests/helper_preflight_test.sh new file mode 100755 index 00000000..f3b1641c --- /dev/null +++ b/tests/helper_preflight_test.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +set -euo pipefail + +source src/util_functions.sh + +ROOT="$(mktemp -d)" +trap 'rm -rf "${ROOT}"' EXIT + +fail() { + echo "$*" >&2 + exit 1 +} + +assert_mode() { + local path="${1}" + local expected="${2}" + + [[ "$(stat -c '%a' "${path}")" == "${expected}" ]] || + fail "Expected ${path} mode ${expected}" +} + +assert_file_equals() { + local expected="${1}" + local path="${2}" + + [[ "$(<"${path}")" == "${expected}" ]] || + fail "Expected ${path} to contain '${expected}'" +} + +test_helper_version_is_pinned() { + [[ "${VERSION[AVBROOT_SETUP]}" == "a14c242a89abb1a13b8c7474dd8235ee75fd31d6" ]] || + fail "Helper version is not pinned to the compatible commit" +} + +test_helper_contract_preflight() ( + local helper="${ROOT}/contract/tools/my-avbroot-setup" + local helper_head + + WORKDIR="${ROOT}/contract" + mkdir -p "${helper}" + git -C "${helper}" init -q + printf '#!/usr/bin/env python3\n' >"${helper}/patch.py" + git -C "${helper}" add patch.py + git -C "${helper}" -c user.name=test -c user.email=test@example.invalid commit -qm fixture + helper_head="$(git -C "${helper}" rev-parse HEAD)" + VERSION[AVBROOT_SETUP]="${helper_head}" + python() { [[ "${1}" == "${helper}/patch.py" && "${2}" == "--help" ]]; } + + helper_contract_preflight + + VERSION[AVBROOT_SETUP]=0000000000000000000000000000000000000000 + if helper_contract_preflight 2>/dev/null; then + fail "Mismatched helper was accepted" + fi +) + +test_repository_preflight_failure_stops_before_download_and_helper_rewrite() ( + WORKDIR="${ROOT}/ordering" + mkdir -p "${WORKDIR}" + + helper_repository_preflight() { return 1; } + download_ota() { touch "${ROOT}/ota-downloaded"; } + my_avbroot_setup() { touch "${ROOT}/helper-rewritten"; } + create_ota() { my_avbroot_setup; } + + if create_and_make_release >/dev/null 2>&1; then + fail "Failed helper repository preflight was reported as success" + fi + [[ ! -e "${ROOT}/ota-downloaded" ]] || + fail "OTA download ran after helper repository preflight failed" + [[ ! -e "${ROOT}/helper-rewritten" ]] || + fail "Helper rewrite ran after helper repository preflight failed" +) + +test_fresh_path_installs_requirements_before_smoke_and_rewrite() ( + local helper="${ROOT}/fresh/tools/my-avbroot-setup" + local events="${ROOT}/fresh-events" + + WORKDIR="${ROOT}/fresh" + ADDITIONALS[AVBROOT]="false" + ADDITIONALS[AFSR]="false" + ADDITIONALS[CUSTOTA_TOOL]="false" + + check_and_download_dependencies() { + mkdir -p "${helper}" + git -C "${helper}" init -q + printf '#!/usr/bin/env python3\n' >"${helper}/patch.py" + printf 'tomlkit==0.13.2\n' >"${helper}/requirements.txt" + git -C "${helper}" add patch.py requirements.txt + git -C "${helper}" -c user.name=test -c user.email=test@example.invalid commit -qm fixture + VERSION[AVBROOT_SETUP]="$(git -C "${helper}" rev-parse HEAD)" + } + download_ota() { printf 'download\n' >>"${events}"; } + generate_ota_info() { :; } + enable_venv() { printf 'venv\n' >>"${events}"; } + pip() { return 0; } + pip3() { printf 'requirements\n' >>"${events}"; } + python() { + [[ "${1}" == "${helper}/patch.py" && "${2}" == "--help" ]] || return 1 + printf 'smoke\n' >>"${events}" + } + my_avbroot_setup() { printf 'rewrite\n' >>"${events}"; } + patch_ota() { printf 'patch\n' >>"${events}"; } + + create_and_make_release >/dev/null + + assert_file_equals $'download\nvenv\nrequirements\nsmoke\nrewrite\npatch' "${events}" +) + +test_make_directories_keeps_private_paths_private() ( + WORKDIR="${ROOT}/private-workdir" + + make_directories + + assert_mode "${WORKDIR}" 700 + assert_mode "${WORKDIR}/.keys" 700 + assert_mode "${WORKDIR}/tools" 700 +) + +test_helper_version_is_pinned +test_helper_contract_preflight +test_repository_preflight_failure_stops_before_download_and_helper_rewrite +test_fresh_path_installs_requirements_before_smoke_and_rewrite +test_make_directories_keeps_private_paths_private +echo "helper preflight tests: ok" From 59cd4dbd97b64ed13a012c1a69092e6e79b21cd0 Mon Sep 17 00:00:00 2001 From: 0cwa Date: Sat, 8 Aug 2026 18:19:02 +0200 Subject: [PATCH 18/18] fix(helper): remove duplicate preflight check Keep repository identity validation at the pre-download boundary and make the later contract check runtime-only. Split focused tests so identity and smoke failures are independently covered. Co-Authored-By: ruflo-bot --- src/util_functions.sh | 1 - tests/helper_preflight_test.sh | 26 +++++++++++++++++++++----- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/util_functions.sh b/src/util_functions.sh index d2dbb4d4..87f5c1c1 100755 --- a/src/util_functions.sh +++ b/src/util_functions.sh @@ -520,7 +520,6 @@ function helper_repository_preflight() { function helper_contract_preflight() { local helper_dir="${WORKDIR}/tools/my-avbroot-setup" - helper_repository_preflight || return 1 if ! python "${helper_dir}/patch.py" --help >/dev/null 2>&1; then echo "Error: helper patch.py contract smoke check failed" >&2 return 1 diff --git a/tests/helper_preflight_test.sh b/tests/helper_preflight_test.sh index f3b1641c..89f1b748 100755 --- a/tests/helper_preflight_test.sh +++ b/tests/helper_preflight_test.sh @@ -32,11 +32,11 @@ test_helper_version_is_pinned() { fail "Helper version is not pinned to the compatible commit" } -test_helper_contract_preflight() ( - local helper="${ROOT}/contract/tools/my-avbroot-setup" +test_helper_repository_preflight() ( + local helper="${ROOT}/repository/tools/my-avbroot-setup" local helper_head - WORKDIR="${ROOT}/contract" + WORKDIR="${ROOT}/repository" mkdir -p "${helper}" git -C "${helper}" init -q printf '#!/usr/bin/env python3\n' >"${helper}/patch.py" @@ -44,13 +44,28 @@ test_helper_contract_preflight() ( git -C "${helper}" -c user.name=test -c user.email=test@example.invalid commit -qm fixture helper_head="$(git -C "${helper}" rev-parse HEAD)" VERSION[AVBROOT_SETUP]="${helper_head}" + helper_repository_preflight + + VERSION[AVBROOT_SETUP]=0000000000000000000000000000000000000000 + if helper_repository_preflight 2>/dev/null; then + fail "Mismatched helper was accepted" + fi +) + +test_helper_contract_preflight() ( + local helper="${ROOT}/contract/tools/my-avbroot-setup" + + WORKDIR="${ROOT}/contract" + mkdir -p "${helper}" + printf '#!/usr/bin/env python3\n' >"${helper}/patch.py" + helper_repository_preflight() { return 1; } python() { [[ "${1}" == "${helper}/patch.py" && "${2}" == "--help" ]]; } helper_contract_preflight - VERSION[AVBROOT_SETUP]=0000000000000000000000000000000000000000 + python() { return 1; } if helper_contract_preflight 2>/dev/null; then - fail "Mismatched helper was accepted" + fail "Failed helper smoke check was reported as success" fi ) @@ -118,6 +133,7 @@ test_make_directories_keeps_private_paths_private() ( ) test_helper_version_is_pinned +test_helper_repository_preflight test_helper_contract_preflight test_repository_preflight_failure_stops_before_download_and_helper_rewrite test_fresh_path_installs_requirements_before_smoke_and_rewrite