diff --git a/.github/schedules/grapheneos-shiba.toml b/.github/schedules/grapheneos-shiba.toml index 9b685f57..31f34043 100644 --- a/.github/schedules/grapheneos-shiba.toml +++ b/.github/schedules/grapheneos-shiba.toml @@ -20,4 +20,6 @@ MAGISK_PREINIT = "sda10" # These modules are only supported on LineageOS pdx235, not GrapheneOS. 'ADDITIONALS[EXFATPROGS_FSCK]' = false 'ADDITIONALS[FSCK_SYSFS_SEPOLICY]' = false +# Vold remains disabled until an independently qualified binary is pinned. +'ADDITIONALS[LINEAGE_VOLD_EXFAT_TIMEOUT]' = false FORCE_UPDATE = false diff --git a/.github/schedules/lineageos-pdx235.toml b/.github/schedules/lineageos-pdx235.toml index f5bfff21..5fd9228a 100644 --- a/.github/schedules/lineageos-pdx235.toml +++ b/.github/schedules/lineageos-pdx235.toml @@ -20,4 +20,6 @@ MAGISK_PREINIT = "sda47" 'ADDITIONALS[MAS_COMPATIBLE_SEPOLICY]' = true 'ADDITIONALS[EXFATPROGS_FSCK]' = true 'ADDITIONALS[FSCK_SYSFS_SEPOLICY]' = true +# Vold remains disabled until an independently qualified binary is pinned. +'ADDITIONALS[LINEAGE_VOLD_EXFAT_TIMEOUT]' = false FORCE_UPDATE = false diff --git a/.github/workflows/build-rom.yml b/.github/workflows/build-rom.yml index 2bc04f80..75ebd4fb 100644 --- a/.github/workflows/build-rom.yml +++ b/.github/workflows/build-rom.yml @@ -72,6 +72,11 @@ on: required: false type: boolean default: false + lineage-vold-exfat-timeout: + description: Replace pdx235 vold with an independently qualified and SHA-pinned source-built binary + required: false + type: boolean + default: false update-channel: required: true type: string @@ -152,6 +157,7 @@ env: ADDITIONALS_SHIZUKU: ${{ inputs.shizuku }} ADDITIONALS_EXFATPROGS_FSCK: ${{ inputs.exfatprogs-fsck }} ADDITIONALS_FSCK_SYSFS_SEPOLICY: ${{ inputs.fsck-sysfs-sepolicy }} + ADDITIONALS_LINEAGE_VOLD_EXFAT_TIMEOUT: ${{ inputs.lineage-vold-exfat-timeout }} ADDITIONALS_BOOT_ANIMATION: ${{ inputs.boot-animation }} # Publishing inherits the caller job's permission ceiling. A reusable @@ -178,6 +184,11 @@ jobs: DEVICE_CANDIDATE_RECIPIENT_BASE64: ${{ inputs.device-candidate-recipient-base64 }} PUBLISH_REQUEST: ${{ inputs.publish }} run: | + if [[ "${ADDITIONALS_LINEAGE_VOLD_EXFAT_TIMEOUT:-false}" == true ]] && + [[ "${ROM_FAMILY}" != lineageos || "${DEVICE_NAME}" != pdx235 ]]; then + echo '::error::Source-built vold is supported only for LineageOS pdx235' + exit 1 + fi if [[ "${EXPORT_DEVICE_CANDIDATE}" == true ]]; then [[ ("${DEVELOPMENT_PROFILE}" == care-map-pdx235 || "${DEVELOPMENT_PROFILE}" == care-map-pdx235-lineage24) && @@ -229,6 +240,17 @@ jobs: fetch-depth: 0 persist-credentials: ${{ inputs.publish && inputs.release-type != 'build-only' }} + - name: Download approved source-built vold artifact + if: inputs.lineage-vold-exfat-timeout + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + python3 src/ci/fetch_lineage_vold_artifact.py --output-dir "${RUNNER_TEMP}/lineage-vold-artifact" + echo "PIXENEOS_VOLD_ARTIFACT_DIR=${RUNNER_TEMP}/lineage-vold-artifact" >> "${GITHUB_ENV}" + echo "PIXENEOS_VOLD_BASE_BUILD_ID=lineage-24.0-20261016-nightly-pdx235-signed" >> "${GITHUB_ENV}" + - name: Build and verify exfatprogs corresponding-source bundle if: inputs.exfatprogs-fsck && inputs.publish && inputs.release-type != 'build-only' && inputs.rom-family == 'lineageos' && inputs.device-id == 'pdx235' shell: bash diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c119a8d0..c8a4a12a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,6 +99,15 @@ jobs: - name: Test exfatprogs image metadata diagnostics run: python3 tests/exfat_adapter_metadata_test.py + - name: Test source-built Vold module contract + run: python3 tests/lineage_vold_contract_test.py + + - name: Test byte-exact unmodified Vold reproduction contract + run: python3 tests/lineage_vold_baseline_test.py + + - name: Test source-built Vold workflow routing + run: python3 tests/lineage_vold_workflow_contract_test.py + - name: Test pdx235 Custota metadata preparation run: bash tests/pdx235_custota_metadata_test.sh @@ -171,3 +180,96 @@ jobs: signing-mode: disposable release-type: build-only publish: false + + # Manual opt-in on the owned review branch only. No publishing keys, release + # credentials, rootless/Magisk patching, or binary-injection paths are used. + # The runner must have a large isolated scratch volume and Android build deps. + lineage_vold_exact_baseline: + if: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.head_ref == 'codex/lineage-vold-exfat-timeout-pr' && vars.MODOS_VOLD_BASELINE_RUNNER_READY == 'true' && vars.MODOS_LINEAGE_LARGE_SCRATCH != '' }} + runs-on: [self-hosted, linux, x64, modos-lineage-large] + timeout-minutes: 360 + permissions: + contents: read + concurrency: + group: lineage-vold-unmodified-baseline + cancel-in-progress: false + env: + MODOS_LINEAGE_LARGE_SCRATCH: ${{ vars.MODOS_LINEAGE_LARGE_SCRATCH }} + steps: + - name: Checkout exact PR source without retained credentials + uses: actions/checkout@v7 + with: + persist-credentials: false + fetch-depth: 0 + + - name: Check isolated build host and initialize scratch workspace + shell: bash + run: | + set -euo pipefail + for tool in repo avbroot unzip debugfs curl openssl python3 git; do + command -v "${tool}" >/dev/null || { echo "::error::Missing host tool: ${tool}"; exit 1; } + done + scratch="${MODOS_LINEAGE_LARGE_SCRATCH}" + [[ "${scratch}" == /* && -d "${scratch}" && ! -L "${scratch}" ]] || + { echo "::error::A real absolute large scratch directory is required"; exit 1; } + scratch="$(realpath -e -- "${scratch}")" + echo "MODOS_LINEAGE_LARGE_SCRATCH=${scratch}" >> "${GITHUB_ENV}" + free="$(df -PB1 -- "${scratch}" | awk 'END {print $4}')" + (( free >= 220000000000 )) || + { echo "::error::Baseline source build needs at least 220GB available"; exit 1; } + work="$(mktemp -d -- "${scratch}/modos-vold-baseline.XXXXXXXX")" + echo "VOLD_BASELINE_WORK=${work}" >> "${GITHUB_ENV}" + + - name: Resolve exact authenticated 2026-10-16 OTA or reviewed archive + id: official + shell: bash + run: python3 src/ci/resolve_lineage_vold_ota.py --output "${GITHUB_OUTPUT}" + + - name: Download, authenticate and extract original OTA vold + shell: bash + env: + OTA_URL: ${{ steps.official.outputs.ota_url }} + OTA_SHA256: ${{ steps.official.outputs.ota_sha256 }} + run: | + set -euo pipefail + [[ "${OTA_SHA256}" =~ ^[0-9a-f]{64}$ ]] || exit 1 + curl --fail --location --retry 3 --proto '=https' --proto-redir '=https' \ + --output "${VOLD_BASELINE_WORK}/source.zip" "${OTA_URL}" + bash src/ci/prepare_lineage_vold_reference.sh \ + "${VOLD_BASELINE_WORK}/source.zip" "${VOLD_BASELINE_WORK}/reference" "${OTA_SHA256}" + + - name: Sync full immutable LineageOS source manifest + shell: bash + run: | + set -euo pipefail + python3 src/ci/build_lineage_vold.py sync \ + --source-root "${VOLD_BASELINE_WORK}/source" --jobs 4 + + - name: Reproduce unmodified vold and check byte-exact OTA identity + shell: bash + env: + OTA_SHA256: ${{ steps.official.outputs.ota_sha256 }} + run: | + set -euo pipefail + python3 src/ci/build_lineage_vold.py baseline \ + --source-root "${VOLD_BASELINE_WORK}/source" \ + --reference-vold "${VOLD_BASELINE_WORK}/reference/reference-vold" \ + --source-ota-sha256 "${OTA_SHA256}" \ + --output-dir "${VOLD_BASELINE_WORK}/baseline" --jobs 4 + + - name: Preserve comparison receipt only (no signed or patched OTA) + if: always() && env.VOLD_BASELINE_WORK != '' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: lineage-pdx235-original-vold-baseline-report + path: ${{ env.VOLD_BASELINE_WORK }}/baseline/baseline-report.json + if-no-files-found: warn + retention-days: 14 + + - name: Erase isolated source and extracted images + if: always() && env.VOLD_BASELINE_WORK != '' + shell: bash + run: | + set -euo pipefail + [[ "${VOLD_BASELINE_WORK}" == "${MODOS_LINEAGE_LARGE_SCRATCH}"/modos-vold-baseline.* ]] || exit 1 + rm -rf -- "${VOLD_BASELINE_WORK}" diff --git a/.github/workflows/release-lineage.yml b/.github/workflows/release-lineage.yml index e003b207..0aaf55f1 100644 --- a/.github/workflows/release-lineage.yml +++ b/.github/workflows/release-lineage.yml @@ -70,6 +70,11 @@ on: required: false type: boolean default: false + lineage-vold-exfat-timeout: + description: Use the independently qualified, hash-pinned pdx235 vold (requires approved staging artifact) + required: false + type: boolean + default: false disable-system-updater: description: Remove the stock ROM updater app (use with Custota) required: false @@ -145,6 +150,7 @@ jobs: compatible_sepolicy_patching: ${{ steps.definition.outputs.compatible_sepolicy_patching }} exfatprogs_fsck: ${{ steps.definition.outputs.exfatprogs_fsck }} fsck_sysfs_sepolicy: ${{ steps.definition.outputs.fsck_sysfs_sepolicy }} + lineage_vold_exfat_timeout: ${{ steps.definition.outputs.lineage_vold_exfat_timeout }} should_build: ${{ steps.existing.outputs.should_build }} steps: - name: Checkout code @@ -179,6 +185,16 @@ jobs: shell: bash run: python3 src/ci/build_exfatprogs_source_bundle.py --output-dir "${RUNNER_TEMP}/exfatprogs-source" --check-deterministic + - name: Download approved vold artifact for scheduled preflight + if: steps.definition.outputs.lineage_vold_exfat_timeout == 'true' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + python3 src/ci/fetch_lineage_vold_artifact.py --output-dir "${RUNNER_TEMP}/lineage-vold-artifact" + echo "PIXENEOS_VOLD_ARTIFACT_DIR=${RUNNER_TEMP}/lineage-vold-artifact" >> "${GITHUB_ENV}" + echo "PIXENEOS_VOLD_BASE_BUILD_ID=lineage-24.0-20261016-nightly-pdx235-signed" >> "${GITHUB_ENV}" + - name: Check for an already-published matching OTA pair id: existing shell: bash @@ -212,6 +228,7 @@ jobs: compatible-sepolicy-patching: ${{ needs.schedule_config.outputs.compatible_sepolicy_patching == 'true' }} exfatprogs-fsck: ${{ needs.schedule_config.outputs.exfatprogs_fsck == 'true' }} fsck-sysfs-sepolicy: ${{ needs.schedule_config.outputs.fsck_sysfs_sepolicy == 'true' }} + lineage-vold-exfat-timeout: ${{ needs.schedule_config.outputs.lineage_vold_exfat_timeout == 'true' }} allow-unauthorized-adb: false boot-animation: ${{ needs.schedule_config.outputs.boot_animation == 'true' }} release-type: default @@ -236,6 +253,7 @@ jobs: shizuku: ${{ inputs.shizuku }} exfatprogs-fsck: ${{ inputs.exfatprogs-fsck }} fsck-sysfs-sepolicy: ${{ inputs.fsck-sysfs-sepolicy }} + lineage-vold-exfat-timeout: ${{ inputs.lineage-vold-exfat-timeout }} disable-system-updater: ${{ inputs.disable-system-updater }} boot-animation: ${{ inputs.boot-animation }} export-device-candidate: ${{ inputs.export-device-candidate }} diff --git a/README.md b/README.md index dc27fa64..fd378663 100644 --- a/README.md +++ b/README.md @@ -292,6 +292,29 @@ any new OTA assets. Local-unpublished builds do not require source publication. The recorded signed APT metadata is audit provenance; builds verify the lock's package URL, size, and SHA-256, but do not repeat the APT signature check. +### Source-built vold timeout for LineageOS pdx235 + +The experimental `lineage-vold-exfat-timeout` module aims to raise only the +exFAT fsck timeout from 45 to 120 seconds. **It is default-off and has not +passed a real unmodified-binary reproduction test.** Before compiling a patched +`vold`, the dedicated, nonpublishing large-runner CI must build **unmodified +`vold`** from the locked 1,243-project LineageOS 2026-10-16 manifest and +demonstrate a *byte-for-byte match* against the executable extracted from the +publisher-authenticated official OTA. A matching ABI or source revision is +not sufficient. A mismatch blocks all subsequent patched builds. + +After that baseline gate passes, the separate `build` command requires the +baseline report, exact unmodified binary and OTA reference before applying +the isolated exFAT timeout patch. System/product identity, ELF dependencies, +root ownership, actual `vold_exec` metadata, and independently approved +binary SHA-256 are also checked prior to production use. Public scheduled +LineageOS and manual selectors remain **disabled**, and GrapheneOS is excluded. + +See [`docs/lineage-vold-exfat-timeout.md`](docs/lineage-vold-exfat-timeout.md) +for the complete CI runner setup, baseline-first acceptance sequence, +provenance requirements and remaining device qualification gates. No vold OTA +has been authorized for force-publish. + ### Custom boot animation (enabled by default) All builds use the checked-in `custom/boot-animation/bootanimation.zip` by diff --git a/docs/lineage-vold-exfat-timeout.md b/docs/lineage-vold-exfat-timeout.md new file mode 100644 index 00000000..71c9d7d2 --- /dev/null +++ b/docs/lineage-vold-exfat-timeout.md @@ -0,0 +1,176 @@ +# Source-built LineageOS vold for pdx235 + +The `lineage-vold-exfat-timeout` adapter replaces `/system/bin/vold` with a +binary built from the exact source snapshot used by the LineageOS +`lineage-24.0-20261016-nightly-pdx235-signed` OTA. Its patch gives exFAT fsck a +120-second limit while leaving the shared 45-second timeout unchanged. + +The source contract is in [`locks/lineage-vold-exfat-timeout.json`](../locks/lineage-vold-exfat-timeout.json). +The complete official 1,243-project manifest is checked in and hash-pinned at +[`locks/lineage-pdx235-20261016-build-manifest.xml`](../locks/lineage-pdx235-20261016-build-manifest.xml). +The lock also pins the vold source revision, pdx235 device-tree revision, +manifest repository revision, target lunch product, timeout patch digest, and +installed binary path. + +## First gate: reproduce the original unmodified vold + +**Do not patch, inject, or publish anything until an unmodified build of +`/system/bin/vold` exactly matches the executable inside the authenticated +official LineageOS OTA, byte for byte.** Matching the ELF ABI, dependencies or +source revision is not enough. + +The `lineage_vold_exact_baseline` job in [CI](../.github/workflows/ci.yml) +implements this gate. It is nonpublishing, read-only on the GitHub repository, +and restricted to this owned review PR. Because syncing the full 1,243-project +LineageOS tree needs substantial space, ordinary GitHub-hosted CI cannot run +this job. Provision an isolated **self-hosted Linux x64** runner with: + +- the runner label `modos-lineage-large`, Android/LineageOS build prerequisites + and the `repo`, `avbroot`, `debugfs`, `unzip`, `curl`, `openssl`, `git` and + `python3` executables; +- at least **220 GB free** on a dedicated scratch volume; set repository + variable `MODOS_LINEAGE_LARGE_SCRATCH` to that absolute directory; +- repository variable `MODOS_VOLD_BASELINE_RUNNER_READY=true` when that + trusted build runner is available. Until enabled, the heavy PR job is + intentionally skipped. + +The baseline job selects only the exact +`lineage-24.0-20261016-nightly-pdx235-signed.zip`. Its immutable OTA SHA-256 +(`8af46661a1440e98018bb2abb155317d6d4ae54e051160f0786b75c1e6806c82`) +and official mirror URL were recovered from successful, independently signed +ModOS source-verification runs +[#39632433468](https://github.com/0cwa/ModOS/actions/runs/39632433468) +and [#39678546023](https://github.com/0cwa/ModOS/actions/runs/39678546023), +and pinned in the source lock. The job first checks the **official +LineageOS builds API**; if that historical nightly has rotated out of its +list, it uses only the pinned original archive URL and SHA-256. Any +conflicting API digest is rejected. It checks that SHA against downloaded +bytes, verifies the embedded certificate +against the independently pinned official LineageOS publisher public key, +and performs cryptographic whole-OTA verification with `avbroot ota verify`. +It then extracts `system.img` and the original `/system/bin/vold` as +**reference bytes only**. No keys for signing or publishing are accessible. + +Next, with a fresh empty source directory, the job executes: + +```sh +python3 src/ci/build_lineage_vold.py sync \ + --source-root /large/scratch/lineage-pdx235-20261016 --jobs 4 +python3 src/ci/build_lineage_vold.py baseline \ + --source-root /large/scratch/lineage-pdx235-20261016 \ + --reference-vold /large/scratch/reference/reference-vold \ + --source-ota-sha256 "$OFFICIAL_SOURCE_SHA256" \ + --output-dir /large/scratch/unmodified-baseline --jobs 4 +``` + +`baseline` validates all manifest revisions and the clean working trees, +runs the pinned `lineage_pdx235-userdebug` lunch target and `m vold` **without +applying the timeout patch**, and compares the resulting installed executable +against the source OTA reference **byte for byte**. It produces +`baseline-report.json` containing the source OTA identity, full manifest +digest, reference/build SHA-256s, ELF identities, sizes, exact equality flag +and first differing byte offset. A mismatch fails the job, and **no patched +build is allowed**. CI retains the report only, not the large source checkout, +vold binary, decrypted partitions or an OTA. + +If the immutable archive becomes unavailable or the full source cannot be +compiled on the runner, this gate remains blocked. Never substitute a newer +nightly or an unverified mirror. Even a successful fixture/unit test is NOT +a successful real baseline reproduction. + +## Second gate: compile the modified vold only after success + +After inspecting an actual **`byte_identical: true`** receipt from the +full baseline CI run, a separately reviewed patched-build job may be developed. +The baseline CI job intentionally deletes its source checkout, reference and +binary after uploading the receipt. Therefore, the later patched-build job +must re-authenticate the exact source OTA and reproduce its unmodified vold +again, without trusting just the uploaded JSON. Only then can it build the +patched artifact with these mandatory proof inputs: + +```sh +python3 src/ci/build_lineage_vold.py build \ + --source-root /large/scratch/lineage-pdx235-20261016 \ + --output-dir /large/scratch/patched-vold \ + --baseline-report /large/scratch/unmodified-baseline/baseline-report.json \ + --baseline-binary /large/scratch/unmodified-baseline/baseline-vold \ + --reference-vold /large/scratch/reference/reference-vold \ + --jobs 4 +``` + +`build` independently rechecks the receipt against both supplied real +binary files, the pinned manifest and the SHA-256 digests **before** it applies +the narrow exFAT-only timeout patch. It then restores the pristine vold +checkout. A patched artifact directory contains `vold` and +`provenance.json`, including the prior exact baseline's reference digest. +The patch stage is NOT yet wired into the baseline CI job; it must only be +activated after proving the baseline and separately reviewing the result. + +## Injection contract + +When registered by ModOS, the adapter is opt-in and requires the +`lineage-vold-exfat-timeout` module selection. It supports only LineageOS +`pdx235` and the pinned OTA build. Source-built vold requires both the +system and product partitions for validation: the device identity is read from +`/system/build.prop`, and the `ro.lineage.*` release properties are read +from `/product/build.prop` (the pinned LineageOS +`vendor/lineage/config/version.mk` installs them via +`PRODUCT_PRODUCT_PROPERTIES`). The ModOS setup must set +`PIXENEOS_VOLD_ARTIFACT_DIR` to the artifact directory and +`PIXENEOS_VOLD_BASE_BUILD_ID` to +`lineage-24.0-20261016-nightly-pdx235-signed`. + +The module is default-off for both scheduled and manual release workflows. +Their selectors are wired to a staged-artifact acquisition step, but this +step intentionally fails before any download until the reviewed binary and +provenance SHA-256 digests, plus the staging release tag, are pinned in the +source lock. Local-unpublished builds can consume a locally built artifact +without this final publication approval. Build the artifact first, then set +`PIXENEOS_VOLD_ARTIFACT_DIR` to its output directory and +`PIXENEOS_VOLD_BASE_BUILD_ID` to the exact id above in the build process +environment. Selection fails before patching if either input is absent or if +the artifact provenance does not match the lock. + +At injection, the adapter checks the partition-specific properties and the +exact `ro.lineage.version` from the pinned source snapshot. It requires +one root-owned executable `/system/bin/vold`, the actual `vold_exec` SELinux +xattr and expected context mapping, and the same AArch64 interpreter and +dependencies as the original OTA binary. It overwrites the existing ExtFs +entry, verifies all original filesystem metadata, and re-hashes the written +binary. A failed check stops the build before a patched OTA is emitted. + +This module is kept separate from scheduled selections and the SELinux force +release. The current workspace does not have Repo installed and has only about +41 GB free, so the full source sync and Android build have not been run here. + +## Production promotion checklist (not yet complete) + +**This module is not qualified for public OTA use.** The lightweight tests +use synthetic ELF and filesystem fixtures; they do not establish an actual +byte-identical result for the pinned LineageOS OTA. + +1. Provision the dedicated CI runner and complete the **unmodified, + byte-exact baseline** proof described above. Review a real successful + `baseline-report.json`, not fixture output. If any byte differs, investigate + compiler/toolchain/build flags and repeat the proof without weakening it. +2. Only after the unmodified baseline passes, build the two-line timeout + patch with its mandatory evidence inputs. Review the resulting artifact + provenance and binary dependencies against the verified reference. +3. Produce a **nonpublishing** pdx235 OTA, inspect AVB and OTA checks, and + test physical-device boot, file-based encryption, storage mounting and + exFAT fsck. Retain rollback/recovery options. A passing CI compile alone + does not establish device safety. +4. Stage independently reviewed `vold` and `provenance.json` as a dedicated + non-OTA GitHub release, and record their exact SHA-256 digests and staging + tag in the source lock. The source lock's approval fields remain **empty** + until steps 1–3 pass; production injection fails closed. +5. Only after those gates, enable the LineageOS pdx235 schedule selector, + run CI, merge and intentionally force-publish. Verify the full rootless + and Magisk OTA pair plus Custota update delivery. + +**Nightly rotation:** This lock and the original-vold baseline identify +*only* the 2026-10-16 nightly. A newer official source OTA requires a fresh +complete baseline reproduction and patch qualification before it can be +included in rolling scheduled builds. Do not reuse the older vold binary. + +GrapheneOS remains excluded by explicit workflow and schedule guards. diff --git a/env.toml b/env.toml index 3aeae327..9f264220 100644 --- a/env.toml +++ b/env.toml @@ -35,6 +35,7 @@ FORCE_UPDATE = false # 'ADDITIONALS[FDROID_PRIVILEGED_EXTENSION]' = false # 'ADDITIONALS[SHIZUKU]' = false # local example; target image capability checks still apply # 'ADDITIONALS[EXFATPROGS_FSCK]' = false # LineageOS pdx235 only; replace system fsck.exfat +# 'ADDITIONALS[LINEAGE_VOLD_EXFAT_TIMEOUT]' = false # Requires separately built, pinned LineageOS vold artifact [github] # Project/release display name and default release repository outside Actions. diff --git a/locks/lineage-pdx235-20261016-build-manifest.xml b/locks/lineage-pdx235-20261016-build-manifest.xml new file mode 100644 index 00000000..12e245e2 --- /dev/null +++ b/locks/lineage-pdx235-20261016-build-manifest.xml @@ -0,0 +1,1283 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/locks/lineage-vold-exfat-timeout.json b/locks/lineage-vold-exfat-timeout.json new file mode 100644 index 00000000..adc93665 --- /dev/null +++ b/locks/lineage-vold-exfat-timeout.json @@ -0,0 +1,53 @@ +{ + "schema_version": 1, + "module": "lineage-vold-exfat-timeout", + "rom_family": "lineageos", + "device": "pdx235", + "base_build_id": "lineage-24.0-20261016-nightly-pdx235-signed", + "source_ota_sha256": "8af46661a1440e98018bb2abb155317d6d4ae54e051160f0786b75c1e6806c82", + "source_ota_url": "https://mirrorbits.lineageos.org/full/pdx235/20261016/lineage-24.0-20261016-nightly-pdx235-signed.zip", + "approved_binary_sha256": "", + "artifact_distribution": { + "repository": "0cwa/ModOS", + "release_tag": "", + "binary_asset": "vold", + "provenance_asset": "provenance.json", + "provenance_sha256": "" + }, + "manifest": { + "url": "https://mirrorbits.lineageos.org/full/pdx235/20261016/build-manifest.xml", + "path": "locks/lineage-pdx235-20261016-build-manifest.xml", + "sha256": "d4ded1ca84de399497b68d7ac006808059b6be9cf76725029420a3495a2e5468", + "source_sha256": "1c9319c969c66b55543679fd5e0389d08cd14d5c2a91c1274ad698467845d326" + }, + "projects": { + "manifest": { + "name": "LineageOS/android", + "path": "android", + "revision": "d4c6331bc603e5038a9e48f5e526e7f10c8b96d9" + }, + "vold": { + "name": "LineageOS/android_system_vold", + "path": "system/vold", + "revision": "11d8982f824d52e8122c7aa732207ba66b8f23c8" + }, + "device": { + "name": "LineageOS/android_device_sony_pdx235", + "path": "device/sony/pdx235", + "revision": "0559de69466e1e9af69b121985f592c4f4609283" + } + }, + "build": { + "target": "lineage_pdx235-userdebug", + "command": "m vold", + "artifact": "out/target/product/pdx235/system/bin/vold", + "install_path": "/system/bin/vold", + "elf_class": 64, + "elf_machine": "AArch64", + "interpreter": "/system/bin/linker64" + }, + "patch": { + "path": "patches/lineageos/android_system_vold/11d8982f824d52e8122c7aa732207ba66b8f23c8-exfat-fsck-timeout-120s.patch", + "sha256": "53129785ccea28d828b08766bddaaf594d5602ba819cb8e7abd281b9d5724a90" + } +} diff --git a/patches/lineageos/android_system_vold/11d8982f824d52e8122c7aa732207ba66b8f23c8-exfat-fsck-timeout-120s.patch b/patches/lineageos/android_system_vold/11d8982f824d52e8122c7aa732207ba66b8f23c8-exfat-fsck-timeout-120s.patch new file mode 100644 index 00000000..ef1e0103 --- /dev/null +++ b/patches/lineageos/android_system_vold/11d8982f824d52e8122c7aa732207ba66b8f23c8-exfat-fsck-timeout-120s.patch @@ -0,0 +1,25 @@ +From 11d8982f824d52e8122c7aa732207ba66b8f23c8 Mon Sep 17 00:00:00 2001 +Subject: [PATCH] vold: allow exFAT fsck up to 120 seconds + +Large exFAT volumes can take longer than the generic 45-second timeout to +check. Give exFAT its own two-minute timeout while retaining the existing +timeout for VFAT and other callers. + +This patch is based on the official LineageOS pdx235 build's +LineageOS/android_system_vold revision +11d8982f824d52e8122c7aa732207ba66b8f23c8 (lineage-24.0). + +diff --git a/Utils.h b/Utils.h +--- a/Utils.h ++++ b/Utils.h +@@ -41,2 +41,3 @@ + static constexpr std::chrono::seconds kUntrustedFsckSleepTime(45); ++static constexpr std::chrono::seconds kUntrustedExfatFsckSleepTime(120); + static constexpr std::chrono::seconds kUntrustedMountSleepTime(20); +diff --git a/fs/Exfat.cpp b/fs/Exfat.cpp +--- a/fs/Exfat.cpp ++++ b/fs/Exfat.cpp +@@ -47,2 +47,2 @@ +- int rc = ForkExecvpTimeout(cmd, kUntrustedFsckSleepTime, sFsckUntrustedContext); ++ int rc = ForkExecvpTimeout(cmd, kUntrustedExfatFsckSleepTime, sFsckUntrustedContext); + if (rc == 0) { diff --git a/src/ci/build_lineage_vold.py b/src/ci/build_lineage_vold.py new file mode 100644 index 00000000..815f403e --- /dev/null +++ b/src/ci/build_lineage_vold.py @@ -0,0 +1,402 @@ +#!/usr/bin/env python3 +"""Build the pinned pdx235 vold from an already synchronized LineageOS tree. + +The complete LineageOS source checkout is large and is intentionally not +downloaded implicitly. Prepare it with the pinned official build manifest in +an isolated build workspace, then pass that workspace to this command. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import stat +import subprocess +import sys +import xml.etree.ElementTree as ET + + +ROOT = Path(__file__).resolve().parents[2] +LOCK_PATH = ROOT / "locks/lineage-vold-exfat-timeout.json" +MANIFEST_PATH = ROOT / "locks/lineage-pdx235-20261016-build-manifest.xml" +ADAPTER_PATH = ROOT / "src/lineage_vold.py" + + +class BuildError(RuntimeError): + pass + + +def digest(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def load_contract(*, verify_patch: bool = True) -> tuple[dict, bytes, bytes]: + lock_bytes = LOCK_PATH.read_bytes() + manifest_bytes = MANIFEST_PATH.read_bytes() + lock = json.loads(lock_bytes) + if digest(manifest_bytes) != lock["manifest"]["sha256"]: + raise BuildError("checked-in LineageOS manifest failed its SHA-256 check") + # The unmodified reproduction phase must not even open the timeout patch. + # Only a later explicitly requested patched build verifies and applies it. + patch_bytes = b"" + if verify_patch: + patch_path = ROOT / lock["patch"]["path"] + patch_bytes = patch_path.read_bytes() + if digest(patch_bytes) != lock["patch"]["sha256"]: + raise BuildError("pinned vold timeout patch failed its SHA-256 check") + return lock, manifest_bytes, patch_bytes + + +def project_map(xml_bytes: bytes) -> dict[str, tuple[str, str]]: + try: + root = ET.fromstring(xml_bytes) + except ET.ParseError as exc: + raise BuildError("LineageOS source manifest is malformed XML") from exc + if root.tag != "manifest": + raise BuildError("LineageOS source manifest has an unexpected root element") + result: dict[str, tuple[str, str]] = {} + for project in root.findall("project"): + name, path, revision = ( + project.get("name", ""), + project.get("path", project.get("name", "")), + project.get("revision", ""), + ) + if not name or not path or len(revision) != 40 or any(c not in "0123456789abcdef" for c in revision): + raise BuildError("source manifest contains an unpinned project") + if path in result: + raise BuildError(f"source manifest repeats project path {path}") + result[path] = (name, revision) + if not result: + raise BuildError("source manifest contains no projects") + return result + + +def checked_source_manifest(source_root: Path, expected: dict) -> None: + repo = shutil.which("repo") + if not repo: + raise BuildError("repo tool is required to verify the synchronized LineageOS source tree") + completed = subprocess.run( + [repo, "manifest", "-r"], + cwd=source_root, + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + if completed.returncode: + raise BuildError("repo manifest -r failed: " + completed.stderr.decode("utf-8", "replace")[-2000:]) + actual = project_map(completed.stdout) + if actual != expected: + missing = sorted(set(expected) - set(actual)) + unexpected = sorted(set(actual) - set(expected)) + changed = sorted(path for path in set(expected) & set(actual) if expected[path] != actual[path]) + details = [] + if missing: + details.append("missing=" + ",".join(missing[:8])) + if unexpected: + details.append("unexpected=" + ",".join(unexpected[:8])) + if changed: + details.append("revision-mismatch=" + ",".join(changed[:8])) + raise BuildError("synchronized source does not match the complete pinned manifest (" + "; ".join(details) + ")") + + +def command_sync(source_root: Path, jobs: int | None) -> None: + lock, manifest_bytes, _ = load_contract() + repo = shutil.which("repo") + if not repo: + raise BuildError("repo tool is required to synchronize LineageOS source") + source_root = source_root.expanduser().resolve() + if source_root.exists() and (not source_root.is_dir() or any(source_root.iterdir())): + raise BuildError("source root must be a new or empty directory") + source_root.mkdir(parents=True, exist_ok=True) + manifest_project = lock["projects"]["manifest"] + subprocess.run( + [ + repo, + "init", + "-u", + "https://github.com/LineageOS/android.git", + "-b", + manifest_project["revision"], + ], + cwd=source_root, + check=True, + ) + active_manifest = source_root / ".repo/manifest.xml" + if not active_manifest.exists() and not active_manifest.is_symlink(): + raise BuildError("repo init did not create the active manifest path") + if active_manifest.is_dir(): + raise BuildError("repo active manifest path unexpectedly resolves to a directory") + active_manifest.unlink() + active_manifest.write_bytes(manifest_bytes) + sync_args = [repo, "sync", "--no-clone-bundle"] + if jobs: + sync_args.append(f"-j{jobs}") + subprocess.run(sync_args, cwd=source_root, check=True) + expected = project_map(manifest_bytes) + checked_source_manifest(source_root, expected) + + +def git(source: Path, *args: str) -> str: + result = subprocess.run( + ["git", "-C", str(source), *args], check=False, text=True, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + ) + if result.returncode: + raise BuildError(f"git {' '.join(args)} failed: {result.stderr[-2000:]}") + return result.stdout.strip() + + +def verify_source_checkouts_clean(source_root: Path, expected: dict) -> None: + """Ensure pinned revisions are not overridden by local edits or extra files. + + A repo manifest pins Git HEADs; it does not certify the working-tree bytes. + An Android binary built with dirty dependencies must never be presented as + built from the exact official manifest. + """ + source_root = source_root.resolve(strict=True) + for relative_path, (_name, revision) in sorted(expected.items()): + project_path = source_root / relative_path + if ( + project_path.is_symlink() + or not project_path.is_dir() + or not project_path.resolve().is_relative_to(source_root) + ): + raise BuildError(f"pinned source project is missing or unsafe: {relative_path}") + if git(project_path, "rev-parse", "HEAD") != revision: + raise BuildError(f"pinned source revision differs at: {relative_path}") + # --ignored includes files hidden by .gitignore that could still be + # read by the Android compiler; require a virgin source workspace. + if git(project_path, "status", "--porcelain", "--ignored", "--untracked-files=all"): + raise BuildError(f"pinned source project is dirty: {relative_path}") + + +def ensure_regular_executable(path: Path) -> bytes: + try: + metadata = path.lstat() + except OSError as exc: + raise BuildError(f"expected build output is unavailable: {path}") from exc + if not stat.S_ISREG(metadata.st_mode) or metadata.st_size <= 0 or metadata.st_size > 32 * 1024 * 1024: + raise BuildError("built vold is not a bounded regular file") + if not metadata.st_mode & 0o111: + raise BuildError("built vold is not executable") + return path.read_bytes() + + +def command_baseline( + source_root: Path, output_dir: Path, reference_vold: Path, + source_ota_sha256: str, jobs: int | None, +) -> dict: + """Build UNMODIFIED vold and compare every byte against the official OTA. + + The timeout patch is never applied by this command. Even a dependency- or + ABI-compatible binary is rejected when any byte differs. + """ + from lineage_vold_baseline import ( + BaselineMismatch, comparison_report, read_binary, require_exact_match, write_report, + ) + lock, manifest_bytes, _patch_bytes = load_contract(verify_patch=False) + source_root = source_root.expanduser().resolve(strict=True) + if not (source_root / ".repo/manifest.xml").is_file(): + raise BuildError("baseline source root is not a repo checkout") + expected = project_map(manifest_bytes) + for key in ("manifest", "vold", "device"): + project = lock["projects"][key] + if expected.get(project["path"]) != (project["name"], project["revision"]): + raise BuildError(f"baseline source contract disagrees for {key}") + checked_source_manifest(source_root, expected) + verify_source_checkouts_clean(source_root, expected) + reference = read_binary(reference_vold) + + if output_dir.is_symlink() or (output_dir.exists() and ( + not output_dir.is_dir() or any(output_dir.iterdir()) + )): + raise BuildError("baseline output directory must be new or empty and not a symlink") + output_dir.mkdir(parents=True, exist_ok=True) + output_artifact = source_root / lock["build"]["artifact"] + if output_artifact.is_symlink(): + raise BuildError("baseline build artifact is an unsafe symlink") + # Eliminate stale installed results from a previous, unrelated build. + if output_artifact.is_file(): + output_artifact.unlink() + + jobs_arg = f"-j{jobs}" if jobs else "" + command = ( + "source build/envsetup.sh && " + f"lunch {lock['build']['target']} && " + f"m {jobs_arg} vold" + ).replace(" ", " ") + completed = subprocess.run(["bash", "-euc", command], cwd=source_root, check=False) + if completed.returncode: + raise BuildError("unmodified LineageOS vold build failed") + + built = ensure_regular_executable(output_artifact) + try: + report = comparison_report(reference, built, lock, source_ota_sha256) + except (ValueError, OSError) as exc: + raise BuildError(f"baseline binary validation failed: {exc}") from exc + + # Preserve diagnostic hashes on a mismatch, but never grant a pass. + write_report(output_dir / "baseline-report.json", report) + try: + require_exact_match(report) + except BaselineMismatch as exc: + raise BuildError(str(exc)) from exc + (output_dir / "baseline-vold").write_bytes(built) + print(json.dumps(report, sort_keys=True)) + return report + + +def command_build( + source_root: Path, output_dir: Path, jobs: int | None, + baseline_report: Path, baseline_binary: Path, reference_vold: Path, +) -> None: + lock, manifest_bytes, patch_bytes = load_contract() + # A patched binary must NOT be built before a byte-exact unmodified vold + # has been reproduced and checked against the authenticated source OTA. + from lineage_vold_baseline import verify_baseline_inputs + try: + baseline = verify_baseline_inputs( + baseline_report, reference_vold, baseline_binary, lock + ) + except (ValueError, OSError) as exc: + raise BuildError(f"patched vold is gated on exact baseline reproduction: {exc}") from exc + source_root = source_root.resolve(strict=True) + if not (source_root / ".repo/manifest.xml").exists(): + raise BuildError("source root is not a repo checkout") + expected = project_map(manifest_bytes) + for key in ("manifest", "vold", "device"): + project = lock["projects"][key] + if expected.get(project["path"]) != (project["name"], project["revision"]): + raise BuildError(f"lock and official manifest disagree for project {key}") + checked_source_manifest(source_root, expected) + verify_source_checkouts_clean(source_root, expected) + + vold_project = lock["projects"]["vold"] + vold_root = source_root / vold_project["path"] + if git(vold_root, "rev-parse", "HEAD") != vold_project["revision"]: + raise BuildError("android_system_vold checkout is not the pinned revision") + if git(vold_root, "status", "--porcelain"): + raise BuildError("android_system_vold checkout is dirty; use an isolated source workspace") + + # Resolve only after checking the caller-provided path: resolve() erases + # whether its final component was an unsafe symbolic link. + output_dir = output_dir.expanduser() + if output_dir.is_symlink(): + raise BuildError("artifact output directory must not be a symlink") + output_dir = output_dir.resolve() + if output_dir.exists() and (not output_dir.is_dir() or any(output_dir.iterdir())): + raise BuildError("artifact output directory must be absent or empty") + output_dir.mkdir(parents=True, exist_ok=True) + output_artifact = source_root / lock["build"]["artifact"] + + patch_path = ROOT / lock["patch"]["path"] + check = subprocess.run(["git", "-C", str(vold_root), "apply", "--check", str(patch_path)], check=False) + if check.returncode: + raise BuildError("timeout patch does not apply cleanly to the pinned vold source") + subprocess.run(["git", "-C", str(vold_root), "apply", str(patch_path)], check=True) + try: + jobs_arg = f"-j{jobs}" if jobs else "" + shell_command = ( + "source build/envsetup.sh && " + f"lunch {lock['build']['target']} && " + f"m {jobs_arg} vold".replace(" ", " ") + ) + completed = subprocess.run( + ["bash", "-euc", shell_command], cwd=source_root, check=False + ) + if completed.returncode: + raise BuildError("LineageOS vold build failed") + binary = ensure_regular_executable(output_artifact) + # Reuse the adapter's independent ELF parser for the artifact contract. + sys.path.insert(0, str(ROOT / "src")) + from lineage_vold import _elf_identity + identity = _elf_identity(binary) + if identity["machine"] != lock["build"]["elf_machine"] or identity["interpreter"] != lock["build"]["interpreter"]: + raise BuildError("built vold has an unexpected target ABI") + shutil.copyfile(output_artifact, output_dir / "vold") + os.chmod(output_dir / "vold", 0o755) + provenance = { + "schema_version": 1, + "module": lock["module"], + "base_build_id": lock["base_build_id"], + "manifest_sha256": lock["manifest"]["sha256"], + "vold_revision": vold_project["revision"], + "device_revision": lock["projects"]["device"]["revision"], + "patch_sha256": lock["patch"]["sha256"], + "build_target": lock["build"]["target"], + "build_command": lock["build"]["command"], + "artifact_path": lock["build"]["artifact"], + "binary_sha256": digest(binary), + "baseline_reference_sha256": baseline["reference_sha256"], + "baseline_source_ota_sha256": baseline["source_ota_sha256"], + "elf": { + "elf_class": identity["elf_class"], + "machine": identity["machine"], + "interpreter": identity["interpreter"], + "needed": list(identity["needed"]), + }, + } + (output_dir / "provenance.json").write_text( + json.dumps(provenance, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + print(json.dumps(provenance, sort_keys=True)) + finally: + revert = subprocess.run( + ["git", "-C", str(vold_root), "apply", "--reverse", str(patch_path)], + check=False, + ) + if revert.returncode: + raise BuildError("failed to restore the pinned vold checkout after the build") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="command", required=True) + sync = subparsers.add_parser("sync", help="explicitly download the large exact-revision source tree") + sync.add_argument("--source-root", type=Path, required=True, help="new or empty source checkout directory") + sync.add_argument("--jobs", type=int, help="optional repo sync parallelism") + baseline = subparsers.add_parser( + "baseline", help="build unmodified vold and require exact OTA byte identity" + ) + baseline.add_argument("--source-root", type=Path, required=True) + baseline.add_argument("--output-dir", type=Path, required=True) + baseline.add_argument("--reference-vold", type=Path, required=True) + baseline.add_argument("--source-ota-sha256", required=True) + baseline.add_argument("--jobs", type=int) + build = subparsers.add_parser( + "build", help="build patched vold ONLY after exact baseline proof" + ) + build.add_argument("--source-root", type=Path, required=True, help="complete, clean checkout synced to the locked official build manifest") + build.add_argument("--output-dir", type=Path, required=True, help="new or empty artifact directory") + build.add_argument("--jobs", type=int, help="optional parallel build job count") + build.add_argument("--baseline-report", type=Path, required=True) + build.add_argument("--baseline-binary", type=Path, required=True) + build.add_argument("--reference-vold", type=Path, required=True) + args = parser.parse_args() + if args.jobs is not None and args.jobs < 1: + parser.error("--jobs must be positive") + try: + if args.command == "sync": + command_sync(args.source_root, args.jobs) + elif args.command == "baseline": + command_baseline( + args.source_root, args.output_dir, args.reference_vold, + args.source_ota_sha256, args.jobs, + ) + else: + command_build( + args.source_root, args.output_dir, args.jobs, args.baseline_report, + args.baseline_binary, args.reference_vold, + ) + except (BuildError, ValueError, OSError, subprocess.SubprocessError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/ci/check_lineage_vold_artifact.py b/src/ci/check_lineage_vold_artifact.py new file mode 100644 index 00000000..4db8b777 --- /dev/null +++ b/src/ci/check_lineage_vold_artifact.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Fail-closed provenance and publication gate for a source-built pdx235 vold. + +The hash recorded by provenance.json is self-reported by the builder. A +production OTA additionally needs an independently reviewed SHA-256 pinned +in the checked-in source lock. Until one is approved, only local-unpublished +qualification builds may select this module. +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import re +import sys + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "src")) + +from lineage_vold import BASE_BUILD_ID, VoldModuleError, _artifact, _sha256 # noqa: E402 +from build_lineage_vold import load_contract # noqa: E402 + + +def require_release_approval(lock: dict, actual_sha256: str, scope: str) -> None: + if scope not in ("published", "local-unpublished"): + raise VoldModuleError("vold only supports local-unpublished or approved published output") + if scope != "published": + return + + approved = lock.get("approved_binary_sha256", "") + if not isinstance(approved, str) or not re.fullmatch(r"[0-9a-f]{64}", approved): + raise VoldModuleError( + "published vold is blocked until a separately qualified binary SHA-256 " + "is pinned in locks/lineage-vold-exfat-timeout.json" + ) + if actual_sha256 != approved: + raise VoldModuleError("source-built vold binary differs from the approved publication digest") + + +def verify( + artifact_dir: Path, scope: str, base_build_id: str, source_target: str +) -> str: + lock, _manifest, _patch = load_contract() + if ( + lock["base_build_id"] != BASE_BUILD_ID + or source_target != BASE_BUILD_ID + or base_build_id != BASE_BUILD_ID + ): + raise VoldModuleError("vold source OTA target does not match the pinned release") + binary, _provenance = _artifact(artifact_dir) + sha256 = _sha256(binary) + require_release_approval(lock, sha256, scope) + return sha256 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--artifact-dir", required=True, type=Path) + parser.add_argument("--scope", required=True, choices=("published", "local-unpublished")) + parser.add_argument("--base-build-id", required=True) + parser.add_argument("--source-target", required=True) + args = parser.parse_args() + try: + print(verify(args.artifact_dir, args.scope, args.base_build_id, args.source_target)) + except (OSError, ValueError, KeyError, TypeError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/ci/fetch_lineage_vold_artifact.py b/src/ci/fetch_lineage_vold_artifact.py new file mode 100644 index 00000000..cc95266e --- /dev/null +++ b/src/ci/fetch_lineage_vold_artifact.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Fetch only an independently approved vold binary and provenance release pair. + +The release is a transport, not a source of trust: both asset digests are +reviewed and pinned in the checked-in lock, and the regular-file, ELF, +manifest and provenance checks run again after download. This script never +downloads unapproved binaries or initiates a source build. +""" + +from __future__ import annotations + +import argparse +import hashlib +from pathlib import Path +import re +import shutil +import subprocess +import sys + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "src")) + +from build_lineage_vold import load_contract +from check_lineage_vold_artifact import verify +from lineage_vold import BASE_BUILD_ID, _read_regular_file + + +class ArtifactFetchError(ValueError): + pass + + +def distribution_contract(lock: dict) -> dict: + config = lock.get("artifact_distribution") + if not isinstance(config, dict): + raise ArtifactFetchError("vold distribution contract is not configured") + if config.get("repository") != "0cwa/ModOS": + raise ArtifactFetchError("vold distribution repository is unexpected") + if config.get("binary_asset") != "vold" or config.get("provenance_asset") != "provenance.json": + raise ArtifactFetchError("vold release assets must have the reviewed exact names") + tag = config.get("release_tag") + if not isinstance(tag, str) or not re.fullmatch(r"vold-pdx235-20261016-[a-zA-Z0-9_.-]+", tag): + raise ArtifactFetchError("vold staging release tag is not approved") + for key, digest in ( + ("approved_binary_sha256", lock.get("approved_binary_sha256")), + ("provenance_sha256", config.get("provenance_sha256")), + ): + if not isinstance(digest, str) or not re.fullmatch(r"[0-9a-f]{64}", digest): + raise ArtifactFetchError(f"vold {key} has not been reviewed and pinned") + return config + + +def fetch(output_dir: Path) -> str: + lock, _manifest, _patch = load_contract() + config = distribution_contract(lock) + if lock.get("base_build_id") != BASE_BUILD_ID: + raise ArtifactFetchError("vold staging release is not for the pinned base build") + + if output_dir.is_symlink() or (output_dir.exists() and ( + not output_dir.is_dir() or any(output_dir.iterdir()) + )): + raise ArtifactFetchError("vold artifact destination must be absent or an empty real directory") + output_dir.mkdir(parents=True, exist_ok=True) + try: + subprocess.run( + [ + "gh", "release", "download", config["release_tag"], + "--repo", config["repository"], + "--pattern", config["binary_asset"], + "--pattern", config["provenance_asset"], + "--dir", str(output_dir), + ], + check=True, + ) + metadata = _read_regular_file(output_dir / "provenance.json", 128 * 1024) + if hashlib.sha256(metadata).hexdigest() != config["provenance_sha256"]: + raise ArtifactFetchError("downloaded vold provenance digest does not match the source lock") + return verify(output_dir, "published", BASE_BUILD_ID, BASE_BUILD_ID) + except (OSError, ValueError, subprocess.SubprocessError): + # A partial download cannot be reused by another build. + shutil.rmtree(output_dir) + raise + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output-dir", required=True, type=Path) + args = parser.parse_args() + try: + print(fetch(args.output_dir)) + except (OSError, ValueError, KeyError, subprocess.SubprocessError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/ci/lineage_vold_baseline.py b/src/ci/lineage_vold_baseline.py new file mode 100644 index 00000000..56ba70e5 --- /dev/null +++ b/src/ci/lineage_vold_baseline.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Byte-exact, nonpublishing LineageOS vold reproduction evidence. + +Unmodified vold MUST match the authenticated OTA's extracted executable +byte-for-byte. Matching ELF ABI/dependencies alone does not grant approval. +No timeout patch is loaded or applied by this module. +""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +import re +import stat +import sys + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "src")) +from lineage_vold import _elf_identity # noqa: E402 + + +class BaselineMismatch(ValueError): + pass + + +def read_binary(path: Path) -> bytes: + meta = path.lstat() + if not stat.S_ISREG(meta.st_mode) or path.is_symlink() or not 0 < meta.st_size <= 32 * 1024 * 1024: + raise BaselineMismatch("vold baseline input is not a bounded regular file") + data = path.read_bytes() + if len(data) != meta.st_size: + raise BaselineMismatch("vold baseline input size changed") + return data + + +def comparison_report(reference: bytes, built: bytes, lock: dict, source_sha256: str) -> dict: + if not isinstance(source_sha256, str) or not re.fullmatch(r"[0-9a-f]{64}", source_sha256): + raise BaselineMismatch("verified official OTA SHA-256 is required") + if source_sha256 != lock.get("source_ota_sha256"): + raise BaselineMismatch("unmodified reference OTA differs from the pinned authenticated source SHA-256") + base = lock["base_build_id"] + if base != "lineage-24.0-20261016-nightly-pdx235-signed": + raise BaselineMismatch("source lock refers to a different LineageOS baseline") + # Parse both ELF files even when they differ: we need to know whether a + # mismatch is an ABI problem as well as an exact byte mismatch. + reference_elf = _elf_identity(reference) + built_elf = _elf_identity(built) + # Canonical JSON-safe shape, so a report round-trips without tuple/list + # confusion when reviewed later by the patch gate. + reference_elf["needed"] = list(reference_elf["needed"]) + built_elf["needed"] = list(built_elf["needed"]) + exact = reference == built + offset = next((i for i, (a, b) in enumerate(zip(reference, built)) if a != b), None) + if offset is None and not exact: + offset = min(len(reference), len(built)) + return { + "schema_version": 1, + "kind": "unmodified-lineage-vold-exact-reproduction", + "base_build_id": base, + "source_ota_sha256": source_sha256, + "manifest_sha256": lock["manifest"]["sha256"], + "vold_revision": lock["projects"]["vold"]["revision"], + "build_target": lock["build"]["target"], + "reference_sha256": hashlib.sha256(reference).hexdigest(), + "built_sha256": hashlib.sha256(built).hexdigest(), + "reference_size": len(reference), + "built_size": len(built), + "elf_identity_matches": reference_elf == built_elf, + "reference_elf": reference_elf, + "built_elf": built_elf, + "byte_identical": exact, + "first_difference_offset": offset, + } + + +def require_exact_match(report: dict) -> None: + if report.get("byte_identical") is not True or report.get("elf_identity_matches") is not True: + raise BaselineMismatch( + "unmodified vold is NOT byte-identical to the authenticated OTA; " + "no patched build may proceed" + ) + if report["built_sha256"] != report["reference_sha256"]: + raise BaselineMismatch("baseline reproduction report contradicts its SHA-256 digests") + + +def verify_baseline_inputs( + report_path: Path, reference_path: Path, baseline_path: Path, lock: dict +) -> dict: + try: + report = json.loads(report_path.read_bytes()) + except (ValueError, UnicodeError, OSError) as exc: + raise BaselineMismatch("reproduction evidence is missing or malformed") from exc + if not isinstance(report, dict) or report.get("kind") != "unmodified-lineage-vold-exact-reproduction": + raise BaselineMismatch("unexpected baseline evidence schema") + if report.get("schema_version") != 1: + raise BaselineMismatch("unsupported baseline evidence version") + source_sha256 = report.get("source_ota_sha256") + expected = comparison_report( + read_binary(reference_path), read_binary(baseline_path), lock, source_sha256 + ) + if report != expected: + raise BaselineMismatch("baseline evidence differs from the current binary bytes or source lock") + require_exact_match(expected) + return expected + + +def write_report(path: Path, data: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + if path.is_symlink(): + raise BaselineMismatch("baseline evidence path is a symbolic link") + path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8") diff --git a/src/ci/load_schedule_definition.sh b/src/ci/load_schedule_definition.sh index cf14b658..73026003 100644 --- a/src/ci/load_schedule_definition.sh +++ b/src/ci/load_schedule_definition.sh @@ -29,6 +29,7 @@ required=( oemunlockonboot disable_system_updater fsck_sysfs_sepolicy + lineage_vold_exfat_timeout fdroid_privileged_extension boot_animation compatible_sepolicy_patching @@ -56,6 +57,7 @@ msd="$(toml_resolve_value msd '')" oemunlockonboot="$(toml_resolve_value oemunlockonboot '')" disable_system_updater="$(toml_resolve_value disable_system_updater '')" fsck_sysfs_sepolicy="$(toml_resolve_value fsck_sysfs_sepolicy '')" +lineage_vold_exfat_timeout="$(toml_resolve_value lineage_vold_exfat_timeout '')" microg="$(toml_resolve_value microg false)" fdroid_privileged_extension="$(toml_resolve_value fdroid_privileged_extension '')" boot_animation="$(toml_resolve_value boot_animation '')" @@ -73,6 +75,16 @@ if [[ -n "${EXPECTED_ROM_FAMILY:-}" && "${rom_family}" != "${EXPECTED_ROM_FAMILY exit 1 fi +if [[ "${lineage_vold_exfat_timeout}" != true && "${lineage_vold_exfat_timeout}" != false ]]; then + echo "::error::Scheduled vold module selection must be a boolean." + exit 1 +fi +if [[ "${lineage_vold_exfat_timeout}" == true && + ("${rom_family}" != lineageos || "${device_id}" != pdx235) ]]; then + echo "::error::Source-built vold is supported only for LineageOS pdx235." + exit 1 +fi + # Fail before exporting a scheduled selection: pdx235-only modules must not # leak into GrapheneOS or other devices, even if a TOML entry is changed. if [[ "${exfatprogs_fsck}" != false || "${fsck_sysfs_sepolicy}" != false ]]; then @@ -105,6 +117,7 @@ esac echo "oemunlockonboot=${oemunlockonboot}" echo "disable_system_updater=${disable_system_updater}" echo "fsck_sysfs_sepolicy=${fsck_sysfs_sepolicy}" + echo "lineage_vold_exfat_timeout=${lineage_vold_exfat_timeout}" echo "microg=${microg}" echo "fdroid_privileged_extension=${fdroid_privileged_extension}" echo "boot_animation=${boot_animation}" @@ -128,6 +141,7 @@ esac echo "ADDITIONALS_OEMUNLOCKONBOOT=${oemunlockonboot}" echo "ADDITIONALS_DISABLE_SYSTEM_UPDATER=${disable_system_updater}" echo "ADDITIONALS_FSCK_SYSFS_SEPOLICY=${fsck_sysfs_sepolicy}" + echo "ADDITIONALS_LINEAGE_VOLD_EXFAT_TIMEOUT=${lineage_vold_exfat_timeout}" echo "ADDITIONALS_MICROG=${microg}" echo "ADDITIONALS_FDROID_PRIVILEGED_EXTENSION=${fdroid_privileged_extension}" echo "ADDITIONALS_BOOT_ANIMATION=${boot_animation}" diff --git a/src/ci/prepare_lineage_vold_reference.sh b/src/ci/prepare_lineage_vold_reference.sh new file mode 100644 index 00000000..5eb46f68 --- /dev/null +++ b/src/ci/prepare_lineage_vold_reference.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-or-later +# Unmodified reference extraction only. NEVER modify OTA partitions or sign. +set -euo pipefail + +if [[ "$#" != 3 ]]; then + echo "Usage: $0 " >&2 + exit 2 +fi +ota="$1" +dest="$2" +expected_sha="$3" + +[[ "${expected_sha}" =~ ^[0-9a-f]{64}$ ]] || { + echo "Error: the official LineageOS API SHA-256 is required." >&2 + exit 1 +} +[[ -f "${ota}" && ! -L "${ota}" ]] || { + echo "Error: expected an existing regular OTA ZIP file." >&2 + exit 1 +} +[[ ! -L "${dest}" && ( ! -e "${dest}" || ( -d "${dest}" && -z "$(ls -A -- "${dest}")" ) ) ]] || { + echo "Error: reference extraction directory must be new or empty." >&2 + exit 1 +} + +for program in unzip openssl sha256sum avbroot debugfs python3; do + command -v "${program}" >/dev/null || { + echo "Error: required baseline host tool ${program} is unavailable." >&2 + exit 1 + } +done + +actual_sha="$(sha256sum -- "${ota}")" +[[ "${actual_sha%% *}" == "${expected_sha}" ]] || { + echo "Error: source OTA differs from the official LineageOS API SHA-256." >&2 + exit 1 +} +mkdir -p -- "${dest}" +cert_path="${dest}/lineage-official-otacert.pem" +unzip -p "${ota}" META-INF/com/android/otacert >"${cert_path}" +[[ -s "${cert_path}" ]] || { + echo "Error: source ZIP lacks its official OTA certificate." >&2 + exit 1 +} + +# Independent LineageOS publisher trust anchor. The certificate *embedded* +# inside the ZIP is NOT trusted by itself: avbroot must also cryptographically +# verify the complete OTA signature against precisely that certificate. +source src/source_care_map_policy.sh +source_care_map_require_lineageos_pdx235_publisher lineageos pdx235 "${ota}" "${cert_path}" +avbroot ota verify --input "${ota}" --cert-ota "${cert_path}" + +# OTA full-payload partition extraction follows successful digest and +# signature verification. No modification, AVB resigning, or OTA emission. +images="${dest}/images" +mkdir -p -- "${images}" +avbroot ota extract --input "${ota}" --directory "${images}" --all +system_image="${images}/system.img" +[[ -f "${system_image}" && ! -L "${system_image}" ]] || { + echo "Error: verified source OTA did not yield system.img." >&2 + exit 1 +} + +reference="${dest}/reference-vold" +debugfs -R "dump -p /system/bin/vold ${reference}" "${system_image}" +[[ -f "${reference}" && ! -L "${reference}" && -s "${reference}" ]] || { + echo "Error: source system partition lacks /system/bin/vold." >&2 + exit 1 +} + +# debugfs retains bytes; chmod adjusts only local extracted metadata so the +# separate baseline builder can safely read an executable reference. +chmod 0755 -- "${reference}" +python3 - "${reference}" <<'PY' +from pathlib import Path +import sys +sys.path.insert(0, "src") +from lineage_vold import _elf_identity +raw = Path(sys.argv[1]).read_bytes() +identity = _elf_identity(raw) +if identity["machine"] != "AArch64" or identity["interpreter"] != "/system/bin/linker64": + raise SystemExit("Error: source OTA vold ELF target is unexpected") +PY + +printf 'source_ota_sha256=%s\n' "${expected_sha}" +sha256sum -- "${reference}" diff --git a/src/ci/resolve_lineage_vold_ota.py b/src/ci/resolve_lineage_vold_ota.py new file mode 100644 index 00000000..963f4ced --- /dev/null +++ b/src/ci/resolve_lineage_vold_ota.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +"""Resolve an EXACT official LineageOS base OTA before baseline comparison. + +Unlike the normal scheduled release provider, this selects one pinned +historical build and refuses to substitute a newer nightly. +""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import re +import subprocess +import sys +from urllib.parse import urlsplit + + +ENDPOINT = "https://download.lineageos.org/api/v2/devices/pdx235/builds" +EXPECTED = "lineage-24.0-20261016-nightly-pdx235-signed.zip" +MAX_METADATA_BYTES = 8 * 1024 * 1024 + + +class SourceSelectionError(ValueError): + pass + + +def choose_official_file(builds: object, expected_sha256: str = "") -> tuple[str, str]: + if not isinstance(builds, list): + raise SourceSelectionError("official LineageOS API did not return a build list") + matches = [] + for build in builds: + if not isinstance(build, dict) or build.get("type") != "nightly": + continue + for artifact in build.get("files", []): + if not isinstance(artifact, dict): + continue + if artifact.get("type") != "nightly" or artifact.get("filename") != EXPECTED: + continue + url, sha = artifact.get("url"), artifact.get("sha256") + if not isinstance(sha, str) or not re.fullmatch(r"[a-f0-9]{64}", sha): + raise SourceSelectionError("official base OTA lacks a valid SHA-256") + if not isinstance(url, str): + raise SourceSelectionError("official base OTA lacks a download URL") + parsed = urlsplit(url) + if ( + parsed.scheme != "https" + or parsed.hostname not in ("download.lineageos.org", "mirrorbits.lineageos.org") + or parsed.username or parsed.password or parsed.fragment + ): + raise SourceSelectionError("official base OTA URL has an unexpected origin") + if not parsed.path.endswith("/" + EXPECTED) and not parsed.path.endswith("/" + EXPECTED + ".zip"): + # LineageOS may use a routing URL instead of a direct ZIP path. + # In that case the trusted API SHA-256 and actual OTA signature + # remain decisive, but arbitrary unrelated paths are not allowed. + if "/full/pdx235/20261016/" not in parsed.path: + raise SourceSelectionError("official base OTA URL has an unexpected path") + matches.append((url, sha)) + unique = set(matches) + if not unique: + raise SourceSelectionError("exact 20261016 pdx235 OTA is absent in official builds API") + if len(unique) != 1: + raise SourceSelectionError("exact 20261016 pdx235 OTA is ambiguous in official builds API") + url, sha = unique.pop() + if expected_sha256 and (not re.fullmatch(r"[a-f0-9]{64}", expected_sha256) or sha != expected_sha256): + raise SourceSelectionError("official API source SHA-256 disagrees with the immutable source lock") + return url, sha + + +def resolve_source(builds: object | None, lock: dict) -> tuple[str, str]: + """API exact-match preferred; verified archival pin is an allowed fallback. + + If the API lists this historical OTA with a *different* hash, refuse it: + do not hide a real source-identity mismatch behind the archival fallback. + """ + url = lock.get("source_ota_url") + sha = lock.get("source_ota_sha256") + if not isinstance(url, str) or not isinstance(sha, str): + raise SourceSelectionError("historical source OTA is not immutably pinned") + if not re.fullmatch(r"[0-9a-f]{64}", sha): + raise SourceSelectionError("historical source OTA has an invalid SHA-256") + parsed = urlsplit(url) + if ( + parsed.scheme != "https" + or parsed.hostname not in ("mirrorbits.lineageos.org", "download.lineageos.org") + or parsed.username or parsed.password or parsed.query or parsed.fragment + or not parsed.path.endswith("/full/pdx235/20261016/" + EXPECTED) + ): + raise SourceSelectionError("historical source OTA URL is not the locked official archive") + + if builds is None: + return url, sha + try: + return choose_official_file(builds, sha) + except SourceSelectionError as exc: + if "is absent in official builds API" in str(exc): + return url, sha + raise + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", type=Path, required=True, help="newline-free GitHub Actions output file") + args = parser.parse_args() + try: + # CLI never accepts caller-supplied API metadata. Tests exercise the + # pure chooser with synthetic fixtures, not the production resolver. + completed = subprocess.run( + ["curl", "--fail", "--location", "--silent", "--show-error", + "--max-time", "60", "--proto", "=https", "--proto-redir", "=https", ENDPOINT], + capture_output=True, check=False, + ) + builds = None + if completed.returncode == 0: + if len(completed.stdout) > MAX_METADATA_BYTES: + raise SourceSelectionError("official build metadata exceeds size limit") + builds = json.loads(completed.stdout) + contract = json.loads((Path(__file__).resolve().parents[2] / "locks/lineage-vold-exfat-timeout.json").read_text()) + url, sha = resolve_source(builds, contract) + if builds is None: + print("Notice: official builds API unavailable; using previously authenticated, SHA-pinned official archive", file=sys.stderr) + if args.output.is_symlink(): + raise SourceSelectionError("output file is an unsafe symlink") + with args.output.open("a", encoding="utf-8") as output: + output.write(f"ota_url={url}\nota_sha256={sha}\n") + except (OSError, ValueError, KeyError, TypeError, subprocess.SubprocessError) as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/ci/selection_variant.sh b/src/ci/selection_variant.sh index 87426b78..4d237e00 100755 --- a/src/ci/selection_variant.sh +++ b/src/ci/selection_variant.sh @@ -48,6 +48,14 @@ function selection_variant_manifest() { SELECTION_EXFATPROGS_FSCK_PROVENANCE_SHA256 SELECTION_MODULE_FSCK_SYSFS_SEPOLICY SELECTION_FSCK_SYSFS_SEPOLICY_ADAPTER_SHA256 + SELECTION_MODULE_LINEAGE_VOLD_EXFAT_TIMEOUT + SELECTION_LINEAGE_VOLD_LOCK_SHA256 + SELECTION_LINEAGE_VOLD_ADAPTER_SHA256 + SELECTION_LINEAGE_VOLD_BUILDER_SHA256 + SELECTION_LINEAGE_VOLD_VERIFIER_SHA256 + SELECTION_LINEAGE_VOLD_PATCH_SHA256 + SELECTION_LINEAGE_VOLD_MANIFEST_SHA256 + SELECTION_LINEAGE_VOLD_BINARY_SHA256 SELECTION_BOOT_ANIMATION ) @@ -76,6 +84,7 @@ function selection_variant_manifest() { SELECTION_MODULE_SHIZUKU \ SELECTION_MODULE_EXFATPROGS_FSCK \ SELECTION_MODULE_FSCK_SYSFS_SEPOLICY \ + SELECTION_MODULE_LINEAGE_VOLD_EXFAT_TIMEOUT \ SELECTION_BOOT_ANIMATION; do value="${!field}" if [[ "${value}" != 'true' && "${value}" != 'false' ]]; then @@ -89,7 +98,14 @@ function selection_variant_manifest() { SELECTION_EXFATPROGS_FSCK_ADAPTER_SHA256 \ SELECTION_EXFATPROGS_FSCK_PAYLOAD_SHA256 \ SELECTION_EXFATPROGS_FSCK_PROVENANCE_SHA256 \ - SELECTION_FSCK_SYSFS_SEPOLICY_ADAPTER_SHA256; do + SELECTION_FSCK_SYSFS_SEPOLICY_ADAPTER_SHA256 \ + SELECTION_LINEAGE_VOLD_LOCK_SHA256 \ + SELECTION_LINEAGE_VOLD_ADAPTER_SHA256 \ + SELECTION_LINEAGE_VOLD_BUILDER_SHA256 \ + SELECTION_LINEAGE_VOLD_VERIFIER_SHA256 \ + SELECTION_LINEAGE_VOLD_PATCH_SHA256 \ + SELECTION_LINEAGE_VOLD_MANIFEST_SHA256 \ + SELECTION_LINEAGE_VOLD_BINARY_SHA256; do value="${!field}" if [[ "${value}" != 'disabled' && ! "${value}" =~ ^[0-9a-f]{64}$ ]]; then echo "Error: selection identity field ${field} is not a SHA-256 digest." >&2 @@ -143,6 +159,34 @@ function selection_variant_manifest() { return 1 fi + if [[ "${SELECTION_MODULE_LINEAGE_VOLD_EXFAT_TIMEOUT}" == 'true' ]]; then + for field in SELECTION_LINEAGE_VOLD_LOCK_SHA256 \ + SELECTION_LINEAGE_VOLD_ADAPTER_SHA256 \ + SELECTION_LINEAGE_VOLD_BUILDER_SHA256 \ + SELECTION_LINEAGE_VOLD_VERIFIER_SHA256 \ + SELECTION_LINEAGE_VOLD_PATCH_SHA256 \ + SELECTION_LINEAGE_VOLD_MANIFEST_SHA256 \ + SELECTION_LINEAGE_VOLD_BINARY_SHA256; do + if [[ ! "${!field}" =~ ^[0-9a-f]{64}$ ]]; then + echo "Error: enabled source-built Vold lacks a checked digest in ${field}." >&2 + return 1 + fi + done + else + for field in SELECTION_LINEAGE_VOLD_LOCK_SHA256 \ + SELECTION_LINEAGE_VOLD_ADAPTER_SHA256 \ + SELECTION_LINEAGE_VOLD_BUILDER_SHA256 \ + SELECTION_LINEAGE_VOLD_VERIFIER_SHA256 \ + SELECTION_LINEAGE_VOLD_PATCH_SHA256 \ + SELECTION_LINEAGE_VOLD_MANIFEST_SHA256 \ + SELECTION_LINEAGE_VOLD_BINARY_SHA256; do + if [[ "${!field}" != 'disabled' ]]; then + echo "Error: disabled source-built Vold has an unexpected digest in ${field}." >&2 + return 1 + fi + done + fi + printf '%s\n' \ 'pixene-module-selection-v1' \ "rom_family=${SELECTION_ROM_FAMILY}" \ @@ -179,6 +223,20 @@ function selection_variant_manifest() { "module.fsck-sysfs-sepolicy=${SELECTION_MODULE_FSCK_SYSFS_SEPOLICY}" \ "fsck_sysfs_sepolicy_adapter_sha256=${SELECTION_FSCK_SYSFS_SEPOLICY_ADAPTER_SHA256}" + # Preserve the existing release fingerprint whenever this optional module + # is disabled; include its artifact digests only for enabled builds. + if [[ "${SELECTION_MODULE_LINEAGE_VOLD_EXFAT_TIMEOUT}" == "true" ]]; then + printf '%s\n' \ + "module.lineage-vold-exfat-timeout=true" \ + "lineage_vold_lock_sha256=${SELECTION_LINEAGE_VOLD_LOCK_SHA256}" \ + "lineage_vold_adapter_sha256=${SELECTION_LINEAGE_VOLD_ADAPTER_SHA256}" \ + "lineage_vold_builder_sha256=${SELECTION_LINEAGE_VOLD_BUILDER_SHA256}" \ + "lineage_vold_verifier_sha256=${SELECTION_LINEAGE_VOLD_VERIFIER_SHA256}" \ + "lineage_vold_patch_sha256=${SELECTION_LINEAGE_VOLD_PATCH_SHA256}" \ + "lineage_vold_manifest_sha256=${SELECTION_LINEAGE_VOLD_MANIFEST_SHA256}" \ + "lineage_vold_binary_sha256=${SELECTION_LINEAGE_VOLD_BINARY_SHA256}" + fi + if [[ "${SELECTION_MODULE_FSCK_SYSFS_SEPOLICY}" == 'true' ]]; then local policy_patcher_root libclang_identity policy_patcher_digest value local -a policy_patcher_inputs diff --git a/src/config_schema.sh b/src/config_schema.sh index 5ea68ba8..d072deb2 100644 --- a/src/config_schema.sh +++ b/src/config_schema.sh @@ -110,6 +110,8 @@ _config_schema_define exfatprogs_fsck 'ADDITIONALS[EXFATPROGS_FSCK]' build boole ADDITIONALS array EXFATPROGS_FSCK ADDITIONALS_EXFATPROGS_FSCK false true caller-or-existing-or-default _config_schema_define fsck_sysfs_sepolicy 'ADDITIONALS[FSCK_SYSFS_SEPOLICY]' build boolean \ ADDITIONALS array FSCK_SYSFS_SEPOLICY ADDITIONALS_FSCK_SYSFS_SEPOLICY false true caller-or-existing-or-default +_config_schema_define lineage_vold_exfat_timeout 'ADDITIONALS[LINEAGE_VOLD_EXFAT_TIMEOUT]' build boolean \ + ADDITIONALS array LINEAGE_VOLD_EXFAT_TIMEOUT ADDITIONALS_LINEAGE_VOLD_EXFAT_TIMEOUT false true caller-or-existing-or-default _config_schema_define project_name PROJECT_NAME github string \ PROJECT_NAME scalar '' PROJECT_NAME ModOS false caller-or-default _config_schema_define release_owner PIXENEOS_RELEASE_OWNER github string \ diff --git a/src/device/check_pdx235_modules.py b/src/device/check_pdx235_modules.py index 1ffc519c..ec29c6ad 100644 --- a/src/device/check_pdx235_modules.py +++ b/src/device/check_pdx235_modules.py @@ -18,7 +18,8 @@ from modos_health.common import Context, item from modos_health.checks import (afsr, alterinstaller, android, bcr, boot_animation, custota, fdroid_privileged_extension, - exfatprogs_fsck, fsck_sysfs_sepolicy, magisk, microg, + exfatprogs_fsck, fsck_sysfs_sepolicy, lineage_vold, + magisk, microg, msd, oemunlockonboot, stock_updater) # Static registry: every enabled module has exactly one owning check file. @@ -36,6 +37,7 @@ ("stock_updater", "ADDITIONALS[DISABLE_SYSTEM_UPDATER]", stock_updater), ("exfatprogs_fsck", "ADDITIONALS[EXFATPROGS_FSCK]", exfatprogs_fsck), ("fsck_sysfs_sepolicy", "ADDITIONALS[FSCK_SYSFS_SEPOLICY]", fsck_sysfs_sepolicy), + ("lineage_vold", "ADDITIONALS[LINEAGE_VOLD_EXFAT_TIMEOUT]", lineage_vold), ("fdroid_privileged_extension", "ADDITIONALS[FDROID_PRIVILEGED_EXTENSION]", fdroid_privileged_extension), ) diff --git a/src/device/modos_health/checks/lineage_vold.py b/src/device/modos_health/checks/lineage_vold.py new file mode 100644 index 00000000..dbad5d40 --- /dev/null +++ b/src/device/modos_health/checks/lineage_vold.py @@ -0,0 +1,23 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Read-only device check for the optional source-built LineageOS vold. + +Presence of /system/bin/vold alone does not demonstrate that our modified +binary was installed, that it boots safely or that its timeout was applied. +""" + +from ..common import Context, item + +ID = "lineage_vold" + + +def check(ctx: Context): + return [ + ctx.file(ID, "vold-executable-present", "/system/bin/vold"), + item( + ID, + "patch-identity-and-runtime-fsck", + "NOT_TESTED", + "ADB file presence cannot attest the installed vold digest, source " + "provenance, exFAT timeout, device encryption or mount behavior", + ), + ] diff --git a/src/lineage_vold.py b/src/lineage_vold.py new file mode 100644 index 00000000..9a2af277 --- /dev/null +++ b/src/lineage_vold.py @@ -0,0 +1,370 @@ +#!/usr/bin/env python3 +"""Inject a vold built from the exact pinned LineageOS pdx235 source.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path, PurePosixPath +import re +import stat +import struct +from typing import Any, Iterable + + +MODULE_NAME = "lineage-vold-exfat-timeout" +BASE_BUILD_ID = "lineage-24.0-20261016-nightly-pdx235-signed" +MANIFEST_SHA256 = "d4ded1ca84de399497b68d7ac006808059b6be9cf76725029420a3495a2e5468" +VOLD_REVISION = "11d8982f824d52e8122c7aa732207ba66b8f23c8" +DEVICE_REVISION = "0559de69466e1e9af69b121985f592c4f4609283" +PATCH_SHA256 = "53129785ccea28d828b08766bddaaf594d5602ba819cb8e7abd281b9d5724a90" +ARTIFACT_DIR_ENV = "PIXENEOS_VOLD_ARTIFACT_DIR" +BASE_BUILD_ENV = "PIXENEOS_VOLD_BASE_BUILD_ID" +TARGET = PurePosixPath("/system/bin/vold") +MAX_BINARY_SIZE = 32 * 1024 * 1024 + + +class VoldModuleError(ValueError): + """The source-built vold or target OTA does not meet the pinned contract.""" + + +def _sha256(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def _require_target_profile(rom_family: str, device: str) -> None: + if rom_family != "lineageos" or device != "pdx235": + raise VoldModuleError("source-built vold is supported only for LineageOS pdx235") + + +def _read_regular_file(path: Path, maximum: int) -> bytes: + try: + metadata = path.lstat() + except OSError as exc: + raise VoldModuleError(f"required vold input is unavailable: {path.name}") from exc + if not stat.S_ISREG(metadata.st_mode) or metadata.st_size <= 0 or metadata.st_size > maximum: + raise VoldModuleError(f"required vold input is not a bounded regular file: {path.name}") + descriptor = None + try: + descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)) + opened = os.fstat(descriptor) + if ( + not stat.S_ISREG(opened.st_mode) + or (opened.st_dev, opened.st_ino) != (metadata.st_dev, metadata.st_ino) + or opened.st_size != metadata.st_size + ): + raise VoldModuleError(f"vold input changed before it was read: {path.name}") + with os.fdopen(descriptor, "rb", closefd=True) as stream: + descriptor = None + data = stream.read(maximum + 1) + except OSError as exc: + raise VoldModuleError(f"required vold input cannot be read: {path.name}") from exc + finally: + if descriptor is not None: + os.close(descriptor) + if len(data) != metadata.st_size or len(data) > maximum: + raise VoldModuleError(f"required vold input has an unexpected size: {path.name}") + return data + + +def _elf_identity(binary: bytes) -> dict[str, Any]: + if len(binary) < 64 or len(binary) > MAX_BINARY_SIZE: + raise VoldModuleError("vold ELF has an invalid size") + if binary[:4] != b"\x7fELF" or binary[4:7] != b"\x02\x01\x01": + raise VoldModuleError("vold is not a little-endian ELF64 executable") + try: + header = struct.unpack_from("<16sHHIQQQIHHHHHH", binary) + except struct.error as exc: + raise VoldModuleError("vold ELF header is truncated") from exc + _, elf_type, machine, version, _, phoff, _, _, ehsize, phentsize, phnum, _, _, _ = header + if elf_type != 3 or machine != 183 or version != 1: + raise VoldModuleError("vold is not an AArch64 position-independent executable") + if ehsize != 64 or phentsize != 56 or not 0 < phnum <= 128: + raise VoldModuleError("vold ELF program-header shape is unsupported") + if phoff + phentsize * phnum > len(binary): + raise VoldModuleError("vold ELF program headers are truncated") + + interpreter: str | None = None + dynamic: tuple[int, int] | None = None + loads: list[tuple[int, int, int]] = [] + for index in range(phnum): + try: + p_type, _, p_offset, p_vaddr, _, p_filesz, _, _ = struct.unpack_from( + " len(binary): + raise VoldModuleError("vold ELF segment exceeds the file") + if p_type == 1: + loads.append((p_offset, p_vaddr, p_filesz)) + elif p_type == 2: + if dynamic is not None: + raise VoldModuleError("vold ELF has multiple dynamic segments") + dynamic = (p_offset, p_filesz) + elif p_type == 3: + if interpreter is not None: + raise VoldModuleError("vold ELF has multiple interpreters") + value = binary[p_offset:p_offset + p_filesz] + if not value.endswith(b"\0") or b"\0" in value[:-1]: + raise VoldModuleError("vold ELF interpreter is malformed") + interpreter = value[:-1].decode("ascii", errors="strict") + if interpreter != "/system/bin/linker64" or dynamic is None or not loads: + raise VoldModuleError("vold ELF interpreter or dynamic segment is unsupported") + + entries: list[tuple[int, int]] = [] + for offset in range(dynamic[0], dynamic[0] + dynamic[1], 16): + if offset + 16 > len(binary): + raise VoldModuleError("vold ELF dynamic table is malformed") + tag, value = struct.unpack_from(" len(binary): + raise VoldModuleError("vold ELF dynamic string table is out of bounds") + + def string_at(index: int) -> str: + if index >= strtab_size: + raise VoldModuleError("vold ELF dynamic string index is out of bounds") + start = strtab_offset + index + end = binary.find(b"\0", start, strtab_offset + strtab_size) + if end < 0: + raise VoldModuleError("vold ELF dynamic string is unterminated") + return binary[start:end].decode("ascii", errors="strict") + + needed = tuple(string_at(value) for tag, value in entries if tag == 1) + if not needed or len(needed) != len(set(needed)): + raise VoldModuleError("vold ELF dependency list is empty or duplicated") + return { + "elf_class": 64, + "machine": "AArch64", + "interpreter": interpreter, + "needed": needed, + } + + +def _artifact(artifact_dir: Path) -> tuple[bytes, dict[str, Any]]: + try: + metadata = artifact_dir.lstat() + except OSError as exc: + raise VoldModuleError("source-built vold artifact directory is unavailable") from exc + if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISDIR(metadata.st_mode): + raise VoldModuleError("source-built vold artifact path is not a safe directory") + binary_path = artifact_dir / "vold" + provenance_path = artifact_dir / "provenance.json" + binary = _read_regular_file(binary_path, MAX_BINARY_SIZE) + provenance_bytes = _read_regular_file(provenance_path, 128 * 1024) + try: + provenance = json.loads(provenance_bytes) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise VoldModuleError("source-built vold provenance is invalid JSON") from exc + expected = { + "schema_version": 1, + "module": MODULE_NAME, + "base_build_id": BASE_BUILD_ID, + "manifest_sha256": MANIFEST_SHA256, + "vold_revision": VOLD_REVISION, + "device_revision": DEVICE_REVISION, + "patch_sha256": PATCH_SHA256, + "build_target": "lineage_pdx235-userdebug", + "build_command": "m vold", + "artifact_path": "out/target/product/pdx235/system/bin/vold", + } + if not isinstance(provenance, dict) or any(provenance.get(key) != value for key, value in expected.items()): + raise VoldModuleError("source-built vold provenance does not match the pinned source contract") + if provenance.get("binary_sha256") != _sha256(binary): + raise VoldModuleError("source-built vold failed its provenance SHA-256 check") + identity = _elf_identity(binary) + if provenance.get("elf") != { + "elf_class": identity["elf_class"], + "machine": identity["machine"], + "interpreter": identity["interpreter"], + "needed": list(identity["needed"]), + }: + raise VoldModuleError("source-built vold ELF metadata differs from its provenance") + return binary, provenance + + +def _build_properties(image_fs: Any, partition: str) -> dict[str, str]: + if partition not in ("system", "product"): + raise VoldModuleError("unsupported partition for build-property verification") + path = image_fs.tree / partition / "build.prop" + data = _read_regular_file(path, 2 * 1024 * 1024) + properties: dict[str, str] = {} + try: + lines = data.decode("utf-8").splitlines() + except UnicodeDecodeError as exc: + raise VoldModuleError("target system build properties are not UTF-8") from exc + for line in lines: + if not line or line.startswith(("#", "!")) or "=" not in line: + continue + key, value = line.split("=", 1) + properties[key.strip()] = value.strip() + return properties + + +def _metadata_snapshot(entry: Any) -> tuple[Any, ...]: + """The pinned ExtFs entry must survive a binary overwrite unchanged.""" + return ( + entry.source, + entry.file_type, + entry.file_mode, + entry.uid, + entry.gid, + entry.atime, + entry.ctime, + entry.mtime, + entry.crtime, + entry.device_major, + entry.device_minor, + entry.symlink_target, + dict(entry.xattrs), + ) + + +def inject_vold(system_fs: Any, product_fs: Any, artifact_dir: Path, base_build_id: str) -> str: + if base_build_id != BASE_BUILD_ID: + raise VoldModuleError("selected OTA build does not match the pinned vold source build") + binary, _ = _artifact(artifact_dir) + system_properties = _build_properties(system_fs, "system") + product_properties = _build_properties(product_fs, "product") + # Modern Android partitions normally advertise ro.product.system.device, + # whereas ro.product.device is resolved by init from several partitions. + device_values = [ + system_properties[key] + for key in ("ro.product.system.device", "ro.product.device", "ro.build.product") + if key in system_properties + ] + if not device_values or any(value != "pdx235" for value in device_values): + raise VoldModuleError("target system image does not identify as pdx235") + if ( + product_properties.get("ro.lineage.build.version") != "24.0" + or product_properties.get("ro.lineage.version") != "24.0-20261016-NIGHTLY-pdx235" + ): + raise VoldModuleError("target system image does not match the pinned 20261016 LineageOS build") + + entries = [entry for entry in system_fs.info.entries if entry.path == TARGET] + if len(entries) != 1 or entries[0].file_type != "RegularFile": + raise VoldModuleError("target /system/bin/vold filesystem metadata is missing or not regular") + entry = entries[0] + if not isinstance(entry.file_mode, int) or not entry.file_mode & 0o111: + raise VoldModuleError("target /system/bin/vold is not executable in filesystem metadata") + if entry.uid not in (None, 0) or entry.gid not in (None, 0): + raise VoldModuleError("target /system/bin/vold must be root-owned") + if not isinstance(entry.xattrs, dict): + raise VoldModuleError("target /system/bin/vold lacks usable SELinux metadata") + label = entry.xattrs.get("security.selinux") + if not isinstance(label, str) or label.rstrip("\0") != "u:object_r:vold_exec:s0": + raise VoldModuleError("target /system/bin/vold has an unexpected SELinux xattr") + + labels = [label for pattern, label in system_fs.contexts if pattern.fullmatch(str(TARGET))] + if not labels or any(label != "u:object_r:vold_exec:s0" for label in labels): + raise VoldModuleError("target /system/bin/vold lacks the expected context mapping") + + # ExtFs may represent a root owner as None, which gets packed back as 0. + # Make that convention explicit before taking the metadata snapshot. + if entry.uid is None: + entry.uid = 0 + if entry.gid is None: + entry.gid = 0 + + target_path = system_fs.tree / TARGET.relative_to("/") + for parent in (system_fs.tree, system_fs.tree / "system", system_fs.tree / "system" / "bin"): + try: + mode = parent.lstat().st_mode + except OSError as exc: + raise VoldModuleError("target /system/bin/vold parent directory is unavailable") from exc + if not stat.S_ISDIR(mode): + raise VoldModuleError("target /system/bin/vold parent directory is unsafe") + old_binary = _read_regular_file(target_path, MAX_BINARY_SIZE) + if _elf_identity(old_binary) != _elf_identity(binary): + raise VoldModuleError("source-built vold ELF ABI or dependencies differ from the OTA vold") + + before = _metadata_snapshot(entry) + try: + with system_fs.open(str(TARGET), "wb", mode=entry.file_mode) as stream: + stream.write(binary) + except (OSError, ValueError) as exc: + raise VoldModuleError("target /system/bin/vold could not be replaced") from exc + if _metadata_snapshot(entry) != before: + raise VoldModuleError("vold injection changed filesystem entry metadata") + if _read_regular_file(target_path, MAX_BINARY_SIZE) != binary: + raise VoldModuleError("injected vold differs from the verified source-built binary") + if len([entry for entry in system_fs.info.entries if entry.path == TARGET]) != 1: + raise VoldModuleError("vold injection unexpectedly changed filesystem metadata entries") + return _sha256(binary) + + +def _module_class() -> type[Any]: + from lib.filesystem import CpioFs, ExtFs + from lib.modules import LegacyCliModule, MissingArgs, ModuleRequirements + + class LineageVoldMod(LegacyCliModule): + NAME = MODULE_NAME + + @classmethod + def add_args(cls, parser: argparse.ArgumentParser) -> None: + parser.add_argument(f"--module-{cls.NAME}", type=Path) + parser.add_argument(f"--module-{cls.NAME}-sig", type=Path) + + @classmethod + def from_args(cls, args: argparse.Namespace) -> "LineageVoldMod": + if getattr(args, "module_lineage_vold_exfat_timeout", None) is None: + raise MissingArgs() + return cls() + + def requirements(self) -> ModuleRequirements: + _require_target_profile( + os.environ.get("PIXENEOS_ROM_FAMILY", ""), + os.environ.get("PIXENEOS_DEVICE_NAME", ""), + ) + # vendor/lineage/config/version.mk supplies ro.lineage.* via + # PRODUCT_PRODUCT_PROPERTIES, not system/build.prop. + return ModuleRequirements(boot_images=set(), ext_images={"system", "product"}, selinux_patching=False) + + def inject( + self, + boot_fs: dict[str, CpioFs], + ext_fs: dict[str, ExtFs], + sepolicies: Iterable[Path], + compatible_sepolicy: bool = False, + ) -> None: + del boot_fs, sepolicies, compatible_sepolicy + _require_target_profile( + os.environ.get("PIXENEOS_ROM_FAMILY", ""), + os.environ.get("PIXENEOS_DEVICE_NAME", ""), + ) + artifact_value = os.environ.get(ARTIFACT_DIR_ENV, "") + if not artifact_value: + raise VoldModuleError("source-built vold artifact directory is not configured") + if "system" not in ext_fs or "product" not in ext_fs: + raise VoldModuleError("system and product images are required for vold validation") + inject_vold( + ext_fs["system"], + ext_fs["product"], + Path(artifact_value), + os.environ.get(BASE_BUILD_ENV, ""), + ) + + return LineageVoldMod + + +if __name__ != "__main__": + try: + LineageVoldMod = _module_class() + except ModuleNotFoundError as exc: + if exc.name != "lib": + raise diff --git a/src/rom_profiles.sh b/src/rom_profiles.sh index ea44f354..00687608 100644 --- a/src/rom_profiles.sh +++ b/src/rom_profiles.sh @@ -233,6 +233,13 @@ function module_selection_fingerprint() { local exfatprogs_fsck_payload_digest="disabled" local exfatprogs_fsck_provenance_digest="disabled" local fsck_sysfs_sepolicy_adapter_digest="disabled" + local lineage_vold_lock_digest="disabled" + local lineage_vold_adapter_digest="disabled" + local lineage_vold_builder_digest="disabled" + local lineage_vold_verifier_digest="disabled" + local lineage_vold_patch_digest="disabled" + local lineage_vold_manifest_digest="disabled" + local lineage_vold_binary_digest="disabled" local entry local -a module_entries=( "afsr:AFSR" @@ -247,6 +254,7 @@ function module_selection_fingerprint() { "shizuku:SHIZUKU" "fsck-sysfs-sepolicy:FSCK_SYSFS_SEPOLICY" "exfatprogs-fsck:EXFATPROGS_FSCK" + "lineage-vold-exfat-timeout:LINEAGE_VOLD_EXFAT_TIMEOUT" ) resolve_rom_profile || return 1 @@ -313,6 +321,33 @@ function module_selection_fingerprint() { } fi + if [[ "${ADDITIONALS[LINEAGE_VOLD_EXFAT_TIMEOUT]}" == 'true' ]]; then + if [[ "${ROM_FAMILY}" != 'lineageos' || "${DEVICE_NAME:-}" != 'pdx235' ]]; then + echo "Error: source-built vold is supported only for LineageOS pdx235." >&2 + return 1 + fi + if [[ -z "${PIXENEOS_VOLD_ARTIFACT_DIR:-}" || + "${PIXENEOS_VOLD_BASE_BUILD_ID:-}" != "lineage-24.0-20261016-nightly-pdx235-signed" ]]; then + echo "Error: source-built vold requires the pinned artifact directory and exact base build id." >&2 + return 1 + fi + lineage_vold_lock_digest="$(_locked_input_digest locks/lineage-vold-exfat-timeout.json)" || return 1 + lineage_vold_adapter_digest="$(_locked_input_digest src/lineage_vold.py)" || return 1 + lineage_vold_builder_digest="$(_locked_input_digest src/ci/build_lineage_vold.py)" || return 1 + lineage_vold_verifier_digest="$(_locked_input_digest src/ci/check_lineage_vold_artifact.py)" || return 1 + lineage_vold_patch_digest="$(_locked_input_digest patches/lineageos/android_system_vold/11d8982f824d52e8122c7aa732207ba66b8f23c8-exfat-fsck-timeout-120s.patch)" || return 1 + lineage_vold_manifest_digest="$(_locked_input_digest locks/lineage-pdx235-20261016-build-manifest.xml)" || return 1 + lineage_vold_binary_digest="$(python3 src/ci/check_lineage_vold_artifact.py \ + --artifact-dir "${PIXENEOS_VOLD_ARTIFACT_DIR}" \ + --scope "${OUTPUT_SCOPE}" \ + --base-build-id "${PIXENEOS_VOLD_BASE_BUILD_ID}" \ + --source-target "${GRAPHENEOS[OTA_TARGET]:-}")" || return 1 + if [[ ! "${lineage_vold_binary_digest}" =~ ^[0-9a-f]{64}$ ]]; then + echo "Error: source-built vold artifact has no valid binary digest." >&2 + return 1 + fi + fi + if [[ "${ADDITIONALS[BOOT_ANIMATION]}" == 'true' ]]; then boot_animation_digest="$(_boot_animation_payload_digest)" || { echo "Error: enabled boot animation payload failed validation." >&2 @@ -394,6 +429,14 @@ function module_selection_fingerprint() { SELECTION_EXFATPROGS_FSCK_PROVENANCE_SHA256="${exfatprogs_fsck_provenance_digest}" SELECTION_MODULE_FSCK_SYSFS_SEPOLICY="${ADDITIONALS[FSCK_SYSFS_SEPOLICY]}" SELECTION_FSCK_SYSFS_SEPOLICY_ADAPTER_SHA256="${fsck_sysfs_sepolicy_adapter_digest}" + SELECTION_MODULE_LINEAGE_VOLD_EXFAT_TIMEOUT="${ADDITIONALS[LINEAGE_VOLD_EXFAT_TIMEOUT]}" + SELECTION_LINEAGE_VOLD_LOCK_SHA256="${lineage_vold_lock_digest}" + SELECTION_LINEAGE_VOLD_ADAPTER_SHA256="${lineage_vold_adapter_digest}" + SELECTION_LINEAGE_VOLD_BUILDER_SHA256="${lineage_vold_builder_digest}" + SELECTION_LINEAGE_VOLD_VERIFIER_SHA256="${lineage_vold_verifier_digest}" + SELECTION_LINEAGE_VOLD_PATCH_SHA256="${lineage_vold_patch_digest}" + SELECTION_LINEAGE_VOLD_MANIFEST_SHA256="${lineage_vold_manifest_digest}" + SELECTION_LINEAGE_VOLD_BINARY_SHA256="${lineage_vold_binary_digest}" SELECTION_BOOT_ANIMATION="${ADDITIONALS[BOOT_ANIMATION]}" SELECTION_BOOT_ANIMATION_SHA256="${boot_animation_digest}" diff --git a/src/util_functions.sh b/src/util_functions.sh index 3d7d5318..5eea8714 100755 --- a/src/util_functions.sh +++ b/src/util_functions.sh @@ -237,6 +237,7 @@ function append_enabled_module_arguments() { "shizuku:SHIZUKU" "fsck-sysfs-sepolicy:FSCK_SYSFS_SEPOLICY" "exfatprogs-fsck:EXFATPROGS_FSCK" + "lineage-vold-exfat-timeout:LINEAGE_VOLD_EXFAT_TIMEOUT" ) for entry in "${module_entries[@]}"; do @@ -718,6 +719,81 @@ function prepare_exfatprogs_fsck_module() { export PIXENEOS_EXFATPROGS_PACKAGE_PATH="${expected_package}" } +# Register the source-built LineageOS vold adapter. The source sync and `m vold` +# build are explicit, separate operations; OTA patching only consumes a +# provenance-checked artifact supplied by the caller. +function prepare_lineage_vold_exfat_timeout_module() { + local helper_root="${1}" + local init_file="${helper_root}/lib/modules/__init__.py" + local registry_file="${helper_root}/lib/modules/registry.py" + local module_source="${helper_root}/lib/modules/lineage_vold.py" + local artifact_dir="${PIXENEOS_VOLD_ARTIFACT_DIR:-}" + local base_build_id="${PIXENEOS_VOLD_BASE_BUILD_ID:-}" + + if [[ "${ADDITIONALS[LINEAGE_VOLD_EXFAT_TIMEOUT]}" != 'true' ]]; then + return 0 + fi + if [[ "${ROM_FAMILY}" != 'lineageos' || "${DEVICE_NAME:-}" != 'pdx235' ]]; then + echo "Error: source-built vold is supported only for LineageOS pdx235." >&2 + return 1 + fi + if [[ -z "${artifact_dir}" || -z "${base_build_id}" ]]; then + echo "Error: set PIXENEOS_VOLD_ARTIFACT_DIR and PIXENEOS_VOLD_BASE_BUILD_ID after building pinned vold." >&2 + return 1 + fi + if [[ ! -f src/lineage_vold.py || -L src/lineage_vold.py || + ! -f "${init_file}" || -L "${init_file}" || + ! -f "${registry_file}" || -L "${registry_file}" || + ! -d "${helper_root}/lib/modules" || -L "${helper_root}/lib/modules" || + -L "${module_source}" ]]; then + echo "Error: the Vold adapter or pinned helper registry path is missing or unsafe." >&2 + return 1 + fi + if ! python3 src/ci/check_lineage_vold_artifact.py \ + --artifact-dir "${artifact_dir}" \ + --scope "${OUTPUT_SCOPE}" \ + --base-build-id "${base_build_id}" \ + --source-target "${GRAPHENEOS[OTA_TARGET]:-}" >/dev/null; then + echo "Error: the source-built vold artifact failed provenance or ELF validation." >&2 + return 1 + fi + + cp -- src/lineage_vold.py "${module_source}" || return 1 + if ! grep -Fq 'def all_modules' "${init_file}" || + ! grep -Fq 'legacy_cli_module_types' "${init_file}" || + ! grep -Fq 'def legacy_cli_module_types' "${registry_file}" || + ! grep -Fq 'result: list[type[LegacyCliModule]] = []' "${registry_file}" || + ! grep -Fq ' return tuple(result)' "${registry_file}"; then + echo "Error: unsupported pinned helper module registry API." >&2 + return 1 + fi + if ! grep -Fq 'from lib.modules.lineage_vold import LineageVoldMod' "${registry_file}"; then + awk '/^ result: list\[type\[LegacyCliModule\]\] = \[\]$/ { + print + print " from lib.modules.lineage_vold import LineageVoldMod" + next + } + {print}' "${registry_file}" >"${registry_file}.tmp" || return 1 + mv -- "${registry_file}.tmp" "${registry_file}" || return 1 + fi + if ! grep -Fq ' result.append(LineageVoldMod)' "${registry_file}"; then + awk '/^ return tuple\(result\)$/ { + print " result.append(LineageVoldMod)" + print + next + } + {print}' "${registry_file}" >"${registry_file}.tmp" || return 1 + mv -- "${registry_file}.tmp" "${registry_file}" || return 1 + fi + mkdir -p -- "${WORKDIR}/modules" "${WORKDIR}/signatures" || return 1 + : >"${WORKDIR}/modules/lineage-vold-exfat-timeout.zip" + : >"${WORKDIR}/signatures/lineage-vold-exfat-timeout.zip.sig" + export PIXENEOS_ROM_FAMILY="${ROM_FAMILY}" + export PIXENEOS_DEVICE_NAME="${DEVICE_NAME}" + export PIXENEOS_VOLD_ARTIFACT_DIR="${artifact_dir}" + export PIXENEOS_VOLD_BASE_BUILD_ID="${base_build_id}" +} + # 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() { @@ -1101,6 +1177,10 @@ function patch_ota() { ! prepare_exfatprogs_fsck_module "${my_avbroot_setup}"; then return 1 fi + if [[ "${ADDITIONALS[LINEAGE_VOLD_EXFAT_TIMEOUT]}" == 'true' ]] && + ! prepare_lineage_vold_exfat_timeout_module "${my_avbroot_setup}"; then + return 1 + fi append_enabled_module_arguments args if [[ "${ADDITIONALS[FDROID_PRIVILEGED_EXTENSION]}" == 'true' ]]; then args+=("${locked_module_args[@]}") diff --git a/tests/lineage_vold_baseline_test.py b/tests/lineage_vold_baseline_test.py new file mode 100644 index 00000000..2ecd21b0 --- /dev/null +++ b/tests/lineage_vold_baseline_test.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +"""Regression gates for baseline-first unmodified pdx235 vold reproduction.""" + +import copy +import hashlib +import json +from pathlib import Path +import sys +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path[:0] = [str(ROOT / "src"), str(ROOT / "src" / "ci"), str(ROOT / "tests")] + +import build_lineage_vold # noqa: E402 +import lineage_vold_baseline as baseline # noqa: E402 +import resolve_lineage_vold_ota as ota # noqa: E402 +from lineage_vold_contract_test import LineageVoldContractTest # noqa: E402 + + +class BaselineFirstTest(unittest.TestCase): + def setUp(self): + self.lock = json.loads((ROOT / "locks/lineage-vold-exfat-timeout.json").read_text()) + self.reference = LineageVoldContractTest.fixture_elf() + self.digest = hashlib.sha256(self.reference).hexdigest() + self.ota_digest = self.lock["source_ota_sha256"] + + def test_identical_binary_is_required_and_report_roundtrips(self): + good = baseline.comparison_report(self.reference, self.reference, self.lock, self.ota_digest) + self.assertTrue(good["byte_identical"]) + self.assertTrue(good["elf_identity_matches"]) + self.assertIsNone(good["first_difference_offset"]) + self.assertEqual(good["reference_sha256"], self.digest) + baseline.require_exact_match(good) + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + (root / "reference-vold").write_bytes(self.reference) + (root / "baseline-vold").write_bytes(self.reference) + baseline.write_report(root / "report.json", good) + self.assertEqual( + baseline.verify_baseline_inputs( + root / "report.json", root / "reference-vold", root / "baseline-vold", self.lock + ), + good, + ) + # A receipt is insufficient if the supposedly reproduced binary + # has been changed between proof and patched build. + tampered = bytearray(self.reference) + tampered[-1] = 1 + (root / "baseline-vold").write_bytes(tampered) + with self.assertRaisesRegex(baseline.BaselineMismatch, "evidence differs"): + baseline.verify_baseline_inputs( + root / "report.json", root / "reference-vold", root / "baseline-vold", self.lock + ) + + def test_same_elf_abi_is_not_sufficient(self): + differing = bytearray(self.reference) + differing[-1] = 1 + report = baseline.comparison_report(self.reference, bytes(differing), self.lock, self.ota_digest) + self.assertTrue(report["elf_identity_matches"]) + self.assertFalse(report["byte_identical"]) + self.assertEqual(report["first_difference_offset"], len(differing) - 1) + with self.assertRaisesRegex(baseline.BaselineMismatch, "NOT byte-identical"): + baseline.require_exact_match(report) + with self.assertRaises(baseline.BaselineMismatch): + baseline.comparison_report(self.reference, self.reference, self.lock, "invalid") + + def test_baseline_only_build_never_applies_patch(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + source = root / "source" + (source / ".repo").mkdir(parents=True) + (source / ".repo" / "manifest.xml").touch() + out_file = source / self.lock["build"]["artifact"] + out_file.parent.mkdir(parents=True) + expected = { + x["path"]: (x["name"], x["revision"]) + for x in self.lock["projects"].values() + } + reference = root / "reference-vold" + reference.write_bytes(self.reference) + + def fake_android_build(argv, **kwargs): + self.assertEqual(argv[:2], ["bash", "-euc"]) + self.assertIn("m ", argv[2]) + self.assertIn(" vold", argv[2]) + self.assertNotIn("apply", argv[2]) + out_file.write_bytes(self.reference) + out_file.chmod(0o755) + return type("Run", (), {"returncode": 0})() + + with ( + patch.object(build_lineage_vold, "load_contract", + return_value=(self.lock, b"manifest", b"patch")), + patch.object(build_lineage_vold, "project_map", return_value=expected), + patch.object(build_lineage_vold, "checked_source_manifest"), + patch.object(build_lineage_vold, "verify_source_checkouts_clean"), + patch.object(build_lineage_vold.subprocess, "run", side_effect=fake_android_build) + ): + artifact = root / "out" + evidence = build_lineage_vold.command_baseline( + source, artifact, reference, self.ota_digest, 2 + ) + self.assertTrue(evidence["byte_identical"]) + self.assertEqual((artifact / "baseline-vold").read_bytes(), self.reference) + self.assertTrue((artifact / "baseline-report.json").exists()) + + # Even if the baseline command succeeded, a caller without the + # actual baseline receipt/reference is not authorized to patch. + with self.assertRaisesRegex(build_lineage_vold.BuildError, "gated on exact baseline"): + build_lineage_vold.command_build( + source, root / "patched", 2, + root / "nonexistent-report.json", reference, reference, + ) + + def test_archived_ota_fallback_is_sha_pinned_and_cannot_hide_disagreement(self): + expected_url = self.lock["source_ota_url"] + expected_sha = self.lock["source_ota_sha256"] + self.assertEqual( + ota.resolve_source(None, self.lock), (expected_url, expected_sha) + ) + self.assertEqual( + ota.resolve_source([], self.lock), (expected_url, expected_sha) + ) + current = [{ + "type": "nightly", + "files": [{ + "type": "nightly", "filename": ota.EXPECTED, + "url": expected_url, + "sha256": expected_sha, + }], + }] + self.assertEqual( + ota.resolve_source(current, self.lock), (expected_url, expected_sha) + ) + forged = copy.deepcopy(current) + forged[0]["files"][0]["sha256"] = "0" * 64 + with self.assertRaisesRegex(ota.SourceSelectionError, "disagrees"): + ota.resolve_source(forged, self.lock) + bad_lock = {**self.lock, "source_ota_url": "https://evil.invalid/" + ota.EXPECTED} + with self.assertRaisesRegex(ota.SourceSelectionError, "locked official archive"): + ota.resolve_source(None, bad_lock) + + def test_official_api_requires_one_exact_nightly_and_digest(self): + record = { + "type": "nightly", + "files": [{ + "type": "nightly", "filename": ota.EXPECTED, + "sha256": self.ota_digest, + "url": "https://mirrorbits.lineageos.org/full/pdx235/20261016/" + ota.EXPECTED, + }], + } + self.assertEqual( + ota.choose_official_file([record], self.ota_digest)[1], self.ota_digest + ) + with self.assertRaisesRegex(ota.SourceSelectionError, "absent"): + ota.choose_official_file([]) + with self.assertRaisesRegex(ota.SourceSelectionError, "disagrees"): + ota.choose_official_file([record], "a" * 64) + changed = copy.deepcopy(record) + changed["files"][0]["sha256"] = "a" * 64 + with self.assertRaisesRegex(ota.SourceSelectionError, "ambiguous"): + ota.choose_official_file([record, changed]) + changed["files"][0]["url"] = "http://mirrorbits.lineageos.org/full/pdx235/20261016/" + ota.EXPECTED + with self.assertRaisesRegex(ota.SourceSelectionError, "unexpected origin"): + ota.choose_official_file([changed]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/lineage_vold_contract_test.py b/tests/lineage_vold_contract_test.py new file mode 100644 index 00000000..1ea7c69c --- /dev/null +++ b/tests/lineage_vold_contract_test.py @@ -0,0 +1,378 @@ +#!/usr/bin/env python3 +"""Check the pinned, opt-in LineageOS vold source/build contract.""" + +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import struct +import sys +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) +sys.path.insert(0, str(ROOT / "src" / "ci")) +import lineage_vold # noqa: E402 +import build_lineage_vold # noqa: E402 +import check_lineage_vold_artifact # noqa: E402 +import fetch_lineage_vold_artifact # noqa: E402 + + +class LineageVoldContractTest(unittest.TestCase): + @staticmethod + def fixture_elf() -> bytes: + binary = bytearray(0x500) + ident = b"\x7fELF\x02\x01\x01" + b"\0" * 9 + struct.pack_into("<16sHHIQQQIHHHHHH", binary, 0, + ident, 3, 183, 1, 0, 64, 0, 0, 64, 56, 3, 0, 0, 0) + headers = ( + (1, 5, 0x300, 0x1000, 0, 0x100, 0x100, 0x1000), + (2, 6, 0x200, 0, 0, 64, 64, 8), + (3, 4, 0x180, 0, 0, 21, 21, 1), + ) + for index, header in enumerate(headers): + struct.pack_into("/dev/null 2>"${vold_error}"; then + fail "GrapheneOS schedule accepted the Lineage-only vold module" +fi +# The typed configuration validator may refuse this prohibited selection +# before our later scope check, even without emitting diagnostic text. Either +# denial is acceptable, provided no output or environment values escape. +[[ ! -s "${vold_output}" && ! -s "${vold_env}" ]] || + fail "GrapheneOS vold schedule leaked forbidden outputs" +rm -f -- "${vold_tampered}" "${vold_output}" "${vold_env}" "${vold_error}" + echo "scheduled build definition tests passed" grep -Fq "needs.schedule_config.outputs.microg == 'true'" .github/workflows/release-lineage.yml ||