diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c119a8d0..4c93ab3b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -99,6 +99,9 @@ jobs:
- name: Test exfatprogs image metadata diagnostics
run: python3 tests/exfat_adapter_metadata_test.py
+ - name: Test unmodified original vold CI-only baseline
+ run: python3 tests/vold_baseline_ci_test.py
+
- name: Test pdx235 Custota metadata preparation
run: bash tests/pdx235_custota_metadata_test.sh
diff --git a/.github/workflows/vold-baseline.yml b/.github/workflows/vold-baseline.yml
new file mode 100644
index 00000000..e7899bb1
--- /dev/null
+++ b/.github/workflows/vold-baseline.yml
@@ -0,0 +1,150 @@
+name: Qualify original LineageOS pdx235 vold (no patch)
+
+# Intentionally manual and nonpublishing. workflow_dispatch is available
+# once this *baseline-only* workflow is merged to the default branch.
+on:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: unmodified-pdx235-vold-baseline
+ cancel-in-progress: false
+
+jobs:
+ prerequisites:
+ runs-on: ubuntu-24.04
+ permissions:
+ contents: read
+ steps:
+ - name: Require a trusted dedicated large build runner
+ shell: bash
+ env:
+ BASELINE_READY: ${{ vars.MODOS_VOLD_BASELINE_RUNNER_READY }}
+ SCRATCH: ${{ vars.MODOS_LINEAGE_LARGE_SCRATCH }}
+ SOURCE_REF: ${{ github.ref }}
+ REPOSITORY: ${{ github.repository }}
+ run: |
+ set -euo pipefail
+ [[ "$REPOSITORY" == '0cwa/ModOS' && "$SOURCE_REF" == refs/heads/main ]] || {
+ echo '::error::Unmodified vold source build is restricted to the repository main branch'
+ exit 1
+ }
+ [[ "$BASELINE_READY" == 'true' && "$SCRATCH" == /* ]] || {
+ echo '::error::Configure MODOS_VOLD_BASELINE_RUNNER_READY=true and MODOS_LINEAGE_LARGE_SCRATCH with an absolute private scratch path'
+ exit 1
+ }
+
+ unmodified_vold:
+ needs: prerequisites
+ runs-on: [self-hosted, linux, x64, modos-lineage-large]
+ timeout-minutes: 360
+ permissions:
+ contents: read
+ env:
+ MODOS_LINEAGE_LARGE_SCRATCH: ${{ vars.MODOS_LINEAGE_LARGE_SCRATCH }}
+ steps:
+ - name: Checkout pinned source and tests
+ uses: actions/checkout@v7
+ with:
+ persist-credentials: false
+ fetch-depth: 0
+
+ - name: Validate isolated host and reserve scratch workspace
+ shell: bash
+ run: |
+ set -euo pipefail
+ for binary in repo avbroot curl unzip openssl sha256sum debugfs git python3 realpath; do
+ command -v "$binary" >/dev/null || {
+ echo "::error::Missing required executable: $binary"
+ exit 1
+ }
+ done
+ path="$MODOS_LINEAGE_LARGE_SCRATCH"
+ [[ "$path" == /* && -d "$path" && ! -L "$path" ]] || {
+ echo '::error::Scratch must be an existing real absolute directory'
+ exit 1
+ }
+ path="$(realpath -e -- "$path")"
+ free="$(df -PB1 "$path" | awk 'END {print $4}')"
+ (( free >= 250000000000 )) || {
+ echo '::error::At least 250 GB free space is required before syncing the complete 1,243-project tree'
+ exit 1
+ }
+ workspace="$(mktemp -d "$path/modos-original-vold.XXXXXXXX")"
+ echo "VOLD_SCRATCH=$path" >> "$GITHUB_ENV"
+ echo "VOLD_WORK=$workspace" >> "$GITHUB_ENV"
+
+ - name: Fetch only the immutable official October 16 source OTA
+ shell: bash
+ run: |
+ set -euo pipefail
+ url="$(python3 - <<'PY'
+ import json
+ from pathlib import Path
+ lock = json.loads(Path('locks/lineage-vold-baseline-20261016.json').read_text())
+ assert lock['source_ota_url'] == (
+ 'https://mirrorbits.lineageos.org/full/pdx235/20261016/'
+ 'lineage-24.0-20261016-nightly-pdx235-signed.zip'
+ )
+ print(lock['source_ota_url'])
+ PY
+ )"
+ curl --fail --location --retry 3 --connect-timeout 30 \
+ --proto '=https' --proto-redir '=https' \
+ --output "$VOLD_WORK/source.zip" "$url"
+
+ - name: Authenticate official publisher and extract original vold
+ shell: bash
+ run: |
+ set -euo pipefail
+ bash src/ci/extract_original_vold.sh "$VOLD_WORK/source.zip" "$VOLD_WORK/reference"
+
+ - name: Synchronize the exact 1,243-project original manifest
+ shell: bash
+ run: |
+ set -euo pipefail
+ python3 src/ci/build_original_vold.py sync \
+ --source-root "$VOLD_WORK/source" --jobs 4
+
+ - name: Build unmodified vold and require byte-exact equality
+ shell: bash
+ run: |
+ set -euo pipefail
+ python3 src/ci/build_original_vold.py compare \
+ --source-root "$VOLD_WORK/source" \
+ --reference-vold "$VOLD_WORK/reference/original-vold" \
+ --output-dir "$VOLD_WORK/baseline" --jobs 4
+ python3 - <<'PY'
+ import json
+ import os
+ from pathlib import Path
+ p = Path(os.environ['VOLD_WORK']) / 'baseline' / 'baseline-report.json'
+ data = json.loads(p.read_text())
+ assert data['byte_identical'] is True
+ with Path(os.environ['GITHUB_STEP_SUMMARY']).open('a') as report:
+ report.write('## Unmodified vold reproduced byte-for-byte\n')
+ report.write(f"Source OTA SHA-256: `{data['official_ota_sha256']}`\n\n")
+ report.write(f"Original vold SHA-256: `{data['reference_sha256']}`\n")
+ PY
+
+ - name: Retain small comparison receipt only
+ if: always() && env.VOLD_WORK != ''
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
+ with:
+ name: original-pdx235-vold-reproducibility-report
+ path: ${{ env.VOLD_WORK }}/baseline/baseline-report.json
+ if-no-files-found: warn
+ retention-days: 14
+
+ - name: Remove original OTA, complete tree, and binary from trusted runner
+ if: always() && env.VOLD_WORK != ''
+ shell: bash
+ run: |
+ set -euo pipefail
+ [[ "$VOLD_WORK" == "$VOLD_SCRATCH"/modos-original-vold.* ]] || {
+ echo '::error::Refusing scratch cleanup outside the isolated baseline workspace'
+ exit 1
+ }
+ rm -rf -- "$VOLD_WORK"
diff --git a/docs/vold-baseline-ci.md b/docs/vold-baseline-ci.md
new file mode 100644
index 00000000..189361f4
--- /dev/null
+++ b/docs/vold-baseline-ci.md
@@ -0,0 +1,91 @@
+# First gate: reproduce LineageOS pdx235's original vold in CI
+
+This is a **separate, nonpublishing, unmodified-source** workflow. It does
+not use or import the exFAT timeout patch, the ModOS vold injection module,
+OTA signing secrets, or any publishing workflow.
+
+A patched vold must never be promoted on the basis of synthetic ELF fixtures
+or mere compatibility. First prove that a clean build of the **unmodified**
+source produces the identical executable found in the cryptographically
+authenticated original LineageOS OTA.
+
+## Exact pinned inputs
+
+- Source: `lineage-24.0-20261016-nightly-pdx235-signed.zip`.
+- Official source OTA SHA-256:
+ `8af46661a1440e98018bb2abb155317d6d4ae54e051160f0786b75c1e6806c82`.
+- Complete 1,243-project release manifest:
+ `locks/lineage-pdx235-20261016-build-manifest.xml`.
+- Source lock: `locks/lineage-vold-baseline-20261016.json`.
+- Exact build: `lineage_pdx235-userdebug`, then `m vold`, without
+ applying any patch or introducing a replacement binary.
+
+These pins were recorded from signed ModOS source-verification runs
+[#39632433468](https://github.com/0cwa/ModOS/actions/runs/39632433468)
+and [#39678546023](https://github.com/0cwa/ModOS/actions/runs/39678546023).
+The complete OTA is reauthenticated during each real baseline run:
+downloaded bytes must match the digest, the embedded certificate's key must
+match the independently pinned LineageOS official publisher key, the ZIP
+certificate must be unique, and `avbroot ota verify` must succeed.
+
+The original `system.img` is extracted read-only, then its
+`/system/bin/vold` is recovered as the reference executable. A full,
+clean, exact-revision LineageOS source checkout builds another unmodified
+`vold` for the **same** pinned product. The resulting report includes
+the reference and built SHA-256s, byte counts and first differing offset.
+
+Only `byte_identical: true` is success. The workflow fails on a mismatch
+and uploads a diagnostic JSON receipt, rather than bypassing the check.
+
+## Runner provisioning (required before first real run)
+
+The GitHub Actions workflow
+[`vold-baseline.yml`](../.github/workflows/vold-baseline.yml) is
+manual-only (`workflow_dispatch`) and runs on the repository's `main`
+branch. A regular 14 GB GitHub-hosted Ubuntu runner is too small.
+
+The repository owner must provision a **trusted, isolated Linux x64
+self-hosted runner**, labeled `modos-lineage-large`, with a dedicated
+scratch filesystem offering at least **250 GB free** and sufficient CPU/RAM
+for an Android 17 `m vold` build. It needs LineageOS/Android build
+prerequisites, the `repo` tool, Git, Python 3, curl, OpenSSL, unzip,
+`debugfs`, and a verified `avbroot` binary supporting `ota verify` and
+`ota extract`. Install and verify these dependencies through the host's
+normal trusted administration process, not from an unreviewed workflow PR.
+
+Configure two repository variables:
+
+- `MODOS_LINEAGE_LARGE_SCRATCH` — absolute path to that real, dedicated
+ scratch directory on the self-hosted runner.
+- `MODOS_VOLD_BASELINE_RUNNER_READY=true` — set only when the owner has
+ confirmed the runner is isolated, sufficiently provisioned and trusted.
+
+The workflow's GitHub token has **read-only** repository permission and does
+not receive signing keys or publishing secrets. Concurrent baseline jobs
+are serialized. A failed preflight stops before allocating the source
+workspace; all scratch content is deleted after uploading the small
+comparison report. Do not register an untrusted shared self-hosted runner
+for this purpose.
+
+Once the baseline-only PR has passed ordinary CI and been merged,
+open **Actions → Qualify original LineageOS pdx235 vold (no patch) →
+Run workflow** on `main`. Any missing runner, unavailable pinned OTA,
+manifest mismatch, build failure, or byte mismatch is a failure, not an
+approval. The full source tree or reference image is never uploaded.
+
+## After the first authentic success
+
+Review the actual run URL and download its
+`original-pdx235-vold-reproducibility-report` artifact. Record the exact
+source, manifest and reference SHA-256, plus the CI commit and run ID.
+Verify the report contains `byte_identical: true` with equal SHA-256s.
+Independent assessment of the real reference extraction and Android
+build remains essential. Do not infer these properties from synthetic tests.
+
+**Only then** return to the separate draft
+[timeout module PR #157](https://github.com/0cwa/ModOS/pull/157) to
+integrate the changed two-line timeout, repeat a clean build, check ABI,
+prepare a nonpublishing OTA, and qualify real device boot, storage and
+exFAT behavior. Never enable scheduled pdx235 builds or force-publish merely
+because the original-build proof passes. Newer LineageOS nightlies require
+their own source/binary compatibility evidence.
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-baseline-20261016.json b/locks/lineage-vold-baseline-20261016.json
new file mode 100644
index 00000000..e87c2ed9
--- /dev/null
+++ b/locks/lineage-vold-baseline-20261016.json
@@ -0,0 +1,39 @@
+{
+ "schema_version": 1,
+ "kind": "lineageos-unmodified-vold-baseline",
+ "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",
+ "manifest": {
+ "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",
+ "elf_class": 64,
+ "elf_machine": "AArch64",
+ "interpreter": "/system/bin/linker64"
+ }
+}
diff --git a/src/ci/build_original_vold.py b/src/ci/build_original_vold.py
new file mode 100644
index 00000000..c12e5982
--- /dev/null
+++ b/src/ci/build_original_vold.py
@@ -0,0 +1,217 @@
+#!/usr/bin/env python3
+"""Build only an UNMODIFIED, pinned LineageOS pdx235 vold and compare to OTA.
+
+This source is intentionally separate from the timeout patch/injection code.
+It never creates, signs, modifies or publishes an OTA.
+"""
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import os
+from pathlib import Path
+import re
+import shutil
+import stat
+import subprocess
+import sys
+import xml.etree.ElementTree as ET
+
+from vold_baseline_compare import BaselineError, comparison, read_vold, require_identical, write_receipt
+
+ROOT = Path(__file__).resolve().parents[2]
+LOCK = ROOT / "locks/lineage-vold-baseline-20261016.json"
+
+
+class BuildError(ValueError):
+ pass
+
+
+def load_contract() -> tuple[dict, bytes]:
+ lock = json.loads(LOCK.read_bytes())
+ if (
+ lock.get("schema_version") != 1
+ or lock.get("kind") != "lineageos-unmodified-vold-baseline"
+ or lock.get("rom_family") != "lineageos"
+ or lock.get("device") != "pdx235"
+ or lock.get("base_build_id") != "lineage-24.0-20261016-nightly-pdx235-signed"
+ or not re.fullmatch(r"[0-9a-f]{64}", lock.get("source_ota_sha256", ""))
+ ):
+ raise BuildError("unmodified vold source contract is incomplete")
+ manifest_path = ROOT / lock["manifest"]["path"]
+ if manifest_path.is_symlink() or not manifest_path.is_file():
+ raise BuildError("pinned manifest is unavailable")
+ manifest = manifest_path.read_bytes()
+ if hashlib.sha256(manifest).hexdigest() != lock["manifest"]["sha256"]:
+ raise BuildError("pinned source manifest digest differs")
+ if lock["build"]["target"] != "lineage_pdx235-userdebug" or lock["build"]["command"] != "m vold":
+ raise BuildError("baseline build target differs from reviewed source")
+ projects = project_map(manifest)
+ if len(projects) != 1243:
+ raise BuildError("unexpected number of projects in source manifest")
+ for project in lock["projects"].values():
+ if projects.get(project["path"]) != (project["name"], project["revision"]):
+ raise BuildError("source lock project differs from checked-in manifest")
+ return lock, manifest
+
+
+def project_map(data: bytes) -> dict[str, tuple[str, str]]:
+ try:
+ root = ET.fromstring(data)
+ except ET.ParseError as exc:
+ raise BuildError("invalid complete source manifest") from exc
+ if root.tag != "manifest":
+ raise BuildError("unexpected source manifest root element")
+ projects = {}
+ for entry in root.findall("project"):
+ name = entry.get("name", "")
+ path = entry.get("path", name)
+ rev = entry.get("revision", "")
+ if not name or not path or not re.fullmatch(r"[a-f0-9]{40}", rev):
+ raise BuildError("source manifest project has no immutable revision")
+ if path in projects:
+ raise BuildError("source manifest repeats a project path")
+ projects[path] = (name, rev)
+ if not projects:
+ raise BuildError("source manifest contains no projects")
+ return projects
+
+
+def command(args: list[str], root: Path, **kwargs: object) -> subprocess.CompletedProcess:
+ return subprocess.run(args, cwd=root, check=True, **kwargs)
+
+
+def verify_checkout(source: Path, manifest: bytes, *, clean: bool = False) -> None:
+ repo = shutil.which("repo")
+ if repo is None:
+ raise BuildError("repo is required to verify complete source revisions")
+ result = subprocess.run(
+ [repo, "manifest", "-r"], cwd=source, check=True, capture_output=True
+ )
+ expected = project_map(manifest)
+ if project_map(result.stdout) != expected:
+ raise BuildError("source checkout does not match the 1,243 pinned revisions")
+ if clean:
+ for relpath, (_name, revision) in sorted(expected.items()):
+ path = source / relpath
+ if path.is_symlink() or not path.is_dir() or not path.resolve().is_relative_to(source):
+ raise BuildError(f"source project is missing or unsafe: {relpath}")
+ head = command(["git", "rev-parse", "HEAD"], path, capture_output=True, text=True).stdout.strip()
+ if head != revision:
+ raise BuildError(f"source revision changed: {relpath}")
+ dirt = command(
+ ["git", "status", "--porcelain", "--untracked-files=all", "--ignored"],
+ path, capture_output=True, text=True,
+ ).stdout.strip()
+ if dirt:
+ raise BuildError(f"source project has unreviewed local content: {relpath}")
+
+
+def sync(source: Path, lock: dict, manifest: bytes, jobs: int) -> None:
+ if source.is_symlink() or (source.exists() and (
+ not source.is_dir() or any(source.iterdir())
+ )):
+ raise BuildError("source directory must be new or empty and not a symlink")
+ repo = shutil.which("repo")
+ if repo is None:
+ raise BuildError("repo tool is unavailable on source build runner")
+ source.mkdir(parents=True, exist_ok=True)
+ manifest_commit = lock["projects"]["manifest"]["revision"]
+ command([
+ repo, "init", "-u", "https://github.com/LineageOS/android.git",
+ "-b", manifest_commit
+ ], source)
+ # Do not overwrite .repo/manifest.xml: Repo manages this file, and
+ # recent versions regenerate it during subsequent sync/init operations.
+ # Instead install the immutable flattened manifest in Repo's manifest
+ # checkout and formally select it with -m, as supported by Repo.
+ manifest_name = "modos-vold-original-20261016.xml"
+ manifest_dir = source / ".repo/manifests"
+ if manifest_dir.is_symlink() or not manifest_dir.is_dir():
+ raise BuildError("repo manifests checkout is unavailable or unsafe")
+ pinned_manifest = manifest_dir / manifest_name
+ if pinned_manifest.exists() or pinned_manifest.is_symlink():
+ raise BuildError("preexisting untrusted local pinned manifest")
+ pinned_manifest.write_bytes(manifest)
+ command([
+ repo, "init", "-u", "https://github.com/LineageOS/android.git",
+ "-b", manifest_commit, "-m", manifest_name
+ ], source)
+ active_manifest = source / ".repo/manifest.xml"
+ if not active_manifest.exists() or active_manifest.is_dir():
+ raise BuildError("repo did not activate the immutable full manifest")
+ if active_manifest.read_bytes() != manifest:
+ raise BuildError("repo's active manifest differs from the pinned source")
+ command([repo, "sync", "--no-clone-bundle", f"-j{jobs}"], source)
+ verify_checkout(source, manifest, clean=True)
+
+
+def build_baseline(
+ source: Path, dest: Path, reference: Path, lock: dict, manifest: bytes,
+ jobs: int
+) -> dict:
+ if source.is_symlink() or not (source / ".repo/manifest.xml").exists():
+ raise BuildError("baseline source directory is missing or unsafe")
+ source = source.resolve(strict=True)
+ verify_checkout(source, manifest, clean=True)
+ official = read_vold(reference)
+ if dest.is_symlink() or (dest.exists() and (
+ not dest.is_dir() or any(dest.iterdir())
+ )):
+ raise BuildError("baseline output directory must be new or empty")
+ dest.mkdir(parents=True, exist_ok=True)
+ artifact = source / lock["build"]["artifact"]
+ if artifact.is_symlink():
+ raise BuildError("Android vold build artifact cannot be a symbolic link")
+ # Never mistake an existing build's vold for a result of this build.
+ if artifact.exists():
+ if not artifact.is_file():
+ raise BuildError("unexpected prior vold output type")
+ artifact.unlink()
+ script = (
+ "source build/envsetup.sh && "
+ f"lunch {lock['build']['target']} && "
+ f"m -j{jobs} vold"
+ )
+ command(["bash", "-euc", script], source)
+ if not artifact.exists() or not artifact.stat().st_mode & 0o111:
+ raise BuildError("source build did not produce an executable vold")
+ built = read_vold(artifact)
+ report = comparison(official, built, lock)
+ write_receipt(dest / "baseline-report.json", report)
+ require_identical(report)
+ return report
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ sub = parser.add_subparsers(dest="mode", required=True)
+ for name in ("sync", "compare"):
+ opt = sub.add_parser(name)
+ opt.add_argument("--source-root", type=Path, required=True)
+ opt.add_argument("--jobs", type=int, default=4)
+ if name == "compare":
+ opt.add_argument("--reference-vold", type=Path, required=True)
+ opt.add_argument("--output-dir", type=Path, required=True)
+ args = parser.parse_args()
+ if not 1 <= args.jobs <= 16:
+ parser.error("--jobs must be between 1 and 16")
+ try:
+ lock, manifest = load_contract()
+ if args.mode == "sync":
+ sync(args.source_root, lock, manifest, args.jobs)
+ else:
+ report = build_baseline(
+ args.source_root, args.output_dir, args.reference_vold,
+ lock, manifest, args.jobs,
+ )
+ print(json.dumps(report, sort_keys=True))
+ except (OSError, BuildError, BaselineError, subprocess.CalledProcessError) as exc:
+ print(f"error: {exc}", file=sys.stderr)
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/src/ci/extract_original_vold.sh b/src/ci/extract_original_vold.sh
new file mode 100644
index 00000000..07b6c018
--- /dev/null
+++ b/src/ci/extract_original_vold.sh
@@ -0,0 +1,80 @@
+#!/usr/bin/env bash
+# SPDX-License-Identifier: AGPL-3.0-or-later
+# Extract a read-only reference executable from the authenticated source OTA.
+# Never touch OTA signing keys, alter a partition or create a patched image.
+set -euo pipefail
+
+if [[ "$#" != 2 ]]; then
+ echo 'Usage: extract_original_vold.sh ' >&2
+ exit 2
+fi
+source_zip="$1"
+out_dir="$2"
+[[ -f "$source_zip" && ! -L "$source_zip" ]] || {
+ echo 'Error: official OTA ZIP is missing or unsafe' >&2
+ exit 1
+}
+[[ ! -L "$out_dir" && ( ! -e "$out_dir" || ( -d "$out_dir" && -z "$(ls -A -- "$out_dir")" ) ) ]] || {
+ echo 'Error: reference directory must be absent or empty' >&2
+ exit 1
+}
+for tool in python3 sha256sum unzip openssl avbroot debugfs; do
+ command -v "$tool" >/dev/null || {
+ echo "Error: missing trusted baseline prerequisite: $tool" >&2
+ exit 1
+ }
+done
+
+expected="$(python3 - <<'PY'
+import json
+from pathlib import Path
+import re
+lock = json.loads(Path("locks/lineage-vold-baseline-20261016.json").read_text())
+sha = lock["source_ota_sha256"]
+assert re.fullmatch("[0-9a-f]{64}", sha)
+print(sha)
+PY
+)"
+digest="$(sha256sum -- "$source_zip" | awk '{print $1}')"
+[[ "$digest" == "$expected" ]] || {
+ echo 'Error: downloaded OTA does not match the reviewed immutable SHA-256' >&2
+ exit 1
+}
+mkdir -p -- "$out_dir"
+cert="$out_dir/source-otacert.pem"
+unzip -p "$source_zip" META-INF/com/android/otacert > "$cert"
+[[ -s "$cert" ]] || {
+ echo 'Error: source ZIP has no embedded signer certificate' >&2
+ exit 1
+}
+
+# This pinned certificate digest comes from LineageOS/update_verifier;
+# verifying an arbitrary embedded certificate alone would NOT be sufficient.
+source src/source_care_map_policy.sh
+source_care_map_require_lineageos_pdx235_publisher lineageos pdx235 "$source_zip" "$cert"
+avbroot ota verify --input "$source_zip" --cert-ota "$cert"
+
+mkdir -p -- "$out_dir/partitions"
+avbroot ota extract --input "$source_zip" --directory "$out_dir/partitions" --all
+system_image="$out_dir/partitions/system.img"
+[[ -f "$system_image" && ! -L "$system_image" ]] || {
+ echo 'Error: authenticated OTA did not contain a regular system.img' >&2
+ exit 1
+}
+
+# The reference bytes come from the original ext4 filesystem, not a rebuilt
+# partition, APK, helper module, or overlay.
+reference="$out_dir/original-vold"
+debugfs -R "dump /system/bin/vold $reference" "$system_image"
+[[ -s "$reference" && -f "$reference" && ! -L "$reference" ]] || {
+ echo 'Error: cannot extract /system/bin/vold from the authentic OTA' >&2
+ exit 1
+}
+python3 - "$reference" <<'PY'
+from pathlib import Path
+import sys
+sys.path.insert(0, "src/ci")
+from vold_baseline_compare import read_vold, sha256
+data = read_vold(Path(sys.argv[1]))
+print(f"official_vold_sha256={sha256(data)}")
+PY
diff --git a/src/ci/vold_baseline_compare.py b/src/ci/vold_baseline_compare.py
new file mode 100644
index 00000000..bb08707a
--- /dev/null
+++ b/src/ci/vold_baseline_compare.py
@@ -0,0 +1,85 @@
+#!/usr/bin/env python3
+"""Compare a clean, unmodified LineageOS vold with the official OTA's bytes.
+
+This file has no import or dependency on the optional patched-vold adapter.
+Matching ELF metadata is not sufficient; the entire binary must match.
+"""
+from __future__ import annotations
+
+import hashlib
+import json
+from pathlib import Path
+import stat
+import struct
+
+
+class BaselineError(ValueError):
+ pass
+
+
+def sha256(data: bytes) -> str:
+ return hashlib.sha256(data).hexdigest()
+
+
+def read_vold(path: Path) -> bytes:
+ metadata = path.lstat()
+ if not stat.S_ISREG(metadata.st_mode) or not 0 < metadata.st_size <= 32 * 1024 * 1024:
+ raise BaselineError("vold must be a bounded regular file")
+ with path.open("rb") as stream:
+ data = stream.read(32 * 1024 * 1024 + 1)
+ if len(data) != metadata.st_size:
+ raise BaselineError("vold changed while being read")
+ if len(data) < 64 or data[:7] != b"\x7fELF\x02\x01\x01":
+ raise BaselineError("vold is not a little-endian ELF64")
+ elf_type, machine = struct.unpack_from(" dict:
+ if contract.get("kind") != "lineageos-unmodified-vold-baseline":
+ raise BaselineError("unrecognized unmodified-build lock")
+ if not reference or not built:
+ raise BaselineError("vold binary comparison inputs must not be empty")
+ first_difference = next(
+ (i for i, (a, b) in enumerate(zip(reference, built)) if a != b),
+ None,
+ )
+ if first_difference is None and len(reference) != len(built):
+ first_difference = min(len(reference), len(built))
+ return {
+ "schema_version": 1,
+ "kind": "unmodified-vold-exact-comparison",
+ "rom_family": "lineageos",
+ "device": "pdx235",
+ "base_build_id": contract["base_build_id"],
+ "official_ota_sha256": contract["source_ota_sha256"],
+ "manifest_sha256": contract["manifest"]["sha256"],
+ "source_vold_revision": contract["projects"]["vold"]["revision"],
+ "build_target": contract["build"]["target"],
+ "reference_sha256": sha256(reference),
+ "built_sha256": sha256(built),
+ "reference_size": len(reference),
+ "built_size": len(built),
+ "byte_identical": reference == built,
+ "first_difference_offset": first_difference,
+ }
+
+
+def require_identical(report: dict) -> None:
+ if report.get("byte_identical") is not True or (
+ report.get("reference_sha256") != report.get("built_sha256")
+ ):
+ raise BaselineError(
+ "unmodified vold differs from authenticated OTA: "
+ "no timeout patch or replacement is authorized"
+ )
+
+
+def write_receipt(path: Path, receipt: dict) -> None:
+ if path.is_symlink():
+ raise BaselineError("baseline report path must not be a symlink")
+ with path.open("x", encoding="utf-8") as output:
+ json.dump(receipt, output, sort_keys=True, indent=2)
+ output.write("\n")
diff --git a/tests/vold_baseline_ci_test.py b/tests/vold_baseline_ci_test.py
new file mode 100644
index 00000000..9f22afc3
--- /dev/null
+++ b/tests/vold_baseline_ci_test.py
@@ -0,0 +1,189 @@
+#!/usr/bin/env python3
+"""The original vold CI must compare genuine bytes, never patch or publish."""
+import hashlib
+import json
+from pathlib import Path
+import struct
+import sys
+import tempfile
+import unittest
+from unittest.mock import patch
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT / "src/ci"))
+import build_original_vold as builder # noqa: E402
+import vold_baseline_compare as comparator # noqa: E402
+
+
+class OriginalVoldBaselineTest(unittest.TestCase):
+ @staticmethod
+ def original_elf() -> bytes:
+ data = bytearray(256)
+ data[:7] = b"\x7fELF\x02\x01\x01"
+ struct.pack_into("= 250000000000", flow)
+ self.assertIn("contents: read", flow)
+ self.assertIn("baseline-report.json", flow)
+ self.assertIn("rm -rf -- \"$VOLD_WORK\"", flow)
+ self.assertIn("src/ci/build_original_vold.py compare", flow)
+ self.assertIn("source_care_map_require_lineageos_pdx235_publisher", auth)
+ self.assertIn("avbroot ota verify", auth)
+ self.assertIn("avbroot ota extract", auth)
+ self.assertIn("comparison(official, built, lock)", original)
+ self.assertIn("verify_checkout(source, manifest, clean=True)", original)
+ for text in (flow, original, auth):
+ self.assertNotIn("git apply", text)
+ self.assertNotIn("ota patch", text)
+ self.assertNotIn("force-publish", text)
+ self.assertNotIn("publish: true", text)
+ self.assertNotIn("lineage_vold_exfat_timeout", text)
+ self.assertNotIn("lineage-vold-exfat-timeout.json", text)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/workflow_contract_test.sh b/tests/workflow_contract_test.sh
index 484e9fc8..3dbdd3ab 100644
--- a/tests/workflow_contract_test.sh
+++ b/tests/workflow_contract_test.sh
@@ -660,7 +660,8 @@ test_workflow_inventory_is_intentional() (
multi-release.yml \
release-lineage.yml \
release.yml \
- renovate.yml)"
+ renovate.yml \
+ vold-baseline.yml)"
actual="$(find "${WORKFLOW_DIR}" -maxdepth 1 -type f \
\( -name '*.yml' -o -name '*.yaml' \) -exec basename {} \; | sort)"