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