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 ||