From 46be97ebaf0ca224dd18fc7197355aa738155bc6 Mon Sep 17 00:00:00 2001 From: 0cwa Date: Wed, 21 Oct 2026 18:04:31 +0200 Subject: [PATCH 01/73] Add optional source-built pdx235 vold timeout module --- .github/workflows/ci.yml | 3 + README.md | 23 + docs/lineage-vold-exfat-timeout.md | 73 + env.toml | 1 + ...lineage-pdx235-20261016-build-manifest.xml | 1283 +++++++++++++++++ locks/lineage-vold-exfat-timeout.json | 43 + ...7ba66b8f23c8-exfat-fsck-timeout-120s.patch | 25 + src/ci/build_lineage_vold.py | 269 ++++ src/ci/selection_variant.sh | 51 +- src/config_schema.sh | 2 + src/lineage_vold.py | 319 ++++ src/rom_profiles.sh | 44 + src/util_functions.sh | 85 ++ tests/lineage_vold_contract_test.py | 153 ++ 14 files changed, 2372 insertions(+), 2 deletions(-) create mode 100644 docs/lineage-vold-exfat-timeout.md create mode 100644 locks/lineage-pdx235-20261016-build-manifest.xml create mode 100644 locks/lineage-vold-exfat-timeout.json create mode 100644 patches/lineageos/android_system_vold/11d8982f824d52e8122c7aa732207ba66b8f23c8-exfat-fsck-timeout-120s.patch create mode 100644 src/ci/build_lineage_vold.py create mode 100644 src/lineage_vold.py create mode 100644 tests/lineage_vold_contract_test.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 563c7e3b..861fb9cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,6 +85,9 @@ 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 pdx235 Custota metadata preparation run: bash tests/pdx235_custota_metadata_test.sh diff --git a/README.md b/README.md index dc27fa64..3daa63a2 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 optional `lineage-vold-exfat-timeout` module replaces `/system/bin/vold` +with a binary built from the complete, hash-pinned LineageOS pdx235 source +manifest. It gives exFAT fsck a 120-second limit; the existing timeout for +other filesystems remains 45 seconds. This option is disabled by default and +is not selected in scheduled builds. Build the artifact explicitly with +`src/ci/build_lineage_vold.py` as documented in +[`docs/lineage-vold-exfat-timeout.md`](docs/lineage-vold-exfat-timeout.md), +then set `PIXENEOS_VOLD_ARTIFACT_DIR` and +`PIXENEOS_VOLD_BASE_BUILD_ID=lineage-24.0-20261016-nightly-pdx235-signed` +before selecting the module. The OTA build validates source and binary +provenance, AArch64 ABI and dynamic dependencies, exact base OTA identity, and +the existing `vold_exec` SELinux label before replacing bytes in the existing +filesystem entry. + +The pinned upstream `android_system_vold` project is Apache-2.0 licensed; the +source patch is limited to the exFAT timeout constant and its call site. The +full LineageOS source sync and Android binary build are intentionally explicit +and were not run by CI. The binary must be built and checked on a suitable +Linux host before use; this module remains absent from release workflows and +scheduled builds until that build path is operationally provisioned. + ### 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..cfc63c10 --- /dev/null +++ b/docs/lineage-vold-exfat-timeout.md @@ -0,0 +1,73 @@ +# 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. + +## Build the artifact + +The source sync and Android build need a dedicated Linux build host with the +LineageOS build prerequisites and ample disk space. Sync is an explicit step; +the module never starts a large source download during an OTA build. Use a new +or empty source directory: + +```sh +python3 src/ci/build_lineage_vold.py sync \ + --source-root /path/to/lineage-pdx235-20261016 \ + --jobs 4 +``` + +The sync command initializes Repo from the manifest repository commit pinned in +the official OTA manifest, installs that complete manifest, synchronizes the +projects, and checks every project revision. Build only `vold` and write its +binary plus provenance to a new or empty artifact directory: + +```sh +python3 src/ci/build_lineage_vold.py build \ + --source-root /path/to/lineage-pdx235-20261016 \ + --output-dir /path/to/lineage-vold-artifact \ + --jobs 4 +``` + +The build command refuses a dirty or mismatched vold checkout. It applies the +locked patch, runs the `lineage_pdx235-userdebug` lunch target and `m vold`, +checks the AArch64 ELF and Android linker, then restores the vold source tree. +The output directory contains `vold` and `provenance.json`; the adapter checks +the source manifest, source revisions, patch digest, binary digest, ABI, and +dynamic dependencies before injection. + +## 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. 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 opt-in and is not enabled in the scheduled build or release +workflow. 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 `ro.product.device`, `ro.lineage.build.version`, +and the exact `ro.lineage.version` from the pinned source snapshot. It requires +one regular executable `/system/bin/vold`, the `vold_exec` SELinux label, and +the same AArch64 interpreter and dependencies as the original OTA binary. It +writes bytes through the existing ExtFs file entry so its image mode, owner, +and SELinux metadata stay attached to the existing path. 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. 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..48b42269 --- /dev/null +++ b/locks/lineage-vold-exfat-timeout.json @@ -0,0 +1,43 @@ +{ + "schema_version": 1, + "module": "lineage-vold-exfat-timeout", + "rom_family": "lineageos", + "device": "pdx235", + "base_build_id": "lineage-24.0-20261016-nightly-pdx235-signed", + "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..a400dea3 --- /dev/null +++ b/src/ci/build_lineage_vold.py @@ -0,0 +1,269 @@ +#!/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() -> tuple[dict, bytes, bytes]: + lock_bytes = LOCK_PATH.read_bytes() + manifest_bytes = MANIFEST_PATH.read_bytes() + patch_path = ROOT / json.loads(lock_bytes)["patch"]["path"] + patch_bytes = patch_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") + 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 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_build(source_root: Path, output_dir: Path, jobs: int | None) -> None: + lock, manifest_bytes, patch_bytes = load_contract() + 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) + + 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") + + output_dir = output_dir.resolve() + if output_dir.exists() and (not output_dir.is_dir() or output_dir.is_symlink() 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), + "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") + build = subparsers.add_parser("build", help="build vold from an already synchronized exact-revision tree") + 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") + 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) + else: + command_build(args.source_root, args.output_dir, args.jobs) + except (BuildError, 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/selection_variant.sh b/src/ci/selection_variant.sh index 8ba114ca..d4a1f568 100755 --- a/src/ci/selection_variant.sh +++ b/src/ci/selection_variant.sh @@ -48,6 +48,13 @@ 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_PATCH_SHA256 + SELECTION_LINEAGE_VOLD_MANIFEST_SHA256 + SELECTION_LINEAGE_VOLD_BINARY_SHA256 SELECTION_BOOT_ANIMATION ) @@ -76,6 +83,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 +97,13 @@ 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_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 +157,32 @@ 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_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_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}" \ @@ -177,7 +217,14 @@ function selection_variant_manifest() { printf '%s\n' \ "module.fsck-sysfs-sepolicy=${SELECTION_MODULE_FSCK_SYSFS_SEPOLICY}" \ - "fsck_sysfs_sepolicy_adapter_sha256=${SELECTION_FSCK_SYSFS_SEPOLICY_ADAPTER_SHA256}" + "fsck_sysfs_sepolicy_adapter_sha256=${SELECTION_FSCK_SYSFS_SEPOLICY_ADAPTER_SHA256}" \ + "module.lineage-vold-exfat-timeout=${SELECTION_MODULE_LINEAGE_VOLD_EXFAT_TIMEOUT}" \ + "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_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}" if [[ "${SELECTION_MODULE_FSCK_SYSFS_SEPOLICY}" == 'true' ]]; then local policy_patcher_root libclang_identity policy_patcher_digest value 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/lineage_vold.py b/src/lineage_vold.py new file mode 100644 index 00000000..c63db848 --- /dev/null +++ b/src/lineage_vold.py @@ -0,0 +1,319 @@ +#!/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(system_fs: Any) -> dict[str, str]: + path = system_fs.tree / "system" / "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 inject_vold(system_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) + properties = _build_properties(system_fs) + if properties.get("ro.product.device") != "pdx235": + raise VoldModuleError("target system image does not identify as pdx235") + if ( + properties.get("ro.lineage.build.version") != "24.0" + or 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] + target_path = system_fs.tree / TARGET.relative_to("/") + if len(entries) != 1 or entries[0].file_type != "RegularFile": + raise VoldModuleError("target /system/bin/vold filesystem metadata is missing or not regular") + if not entries[0].file_mode & 0o111: + raise VoldModuleError("target /system/bin/vold is not executable in filesystem metadata") + try: + target_metadata = target_path.lstat() + except OSError as exc: + raise VoldModuleError("target /system/bin/vold is missing from the unpacked system image") from exc + if stat.S_ISLNK(target_metadata.st_mode) or not stat.S_ISREG(target_metadata.st_mode): + raise VoldModuleError("target /system/bin/vold tree path is not a safe regular file") + old_binary = _read_regular_file(target_path, MAX_BINARY_SIZE) + old_identity = _elf_identity(old_binary) + new_identity = _elf_identity(binary) + if old_identity != new_identity: + raise VoldModuleError("source-built vold ELF ABI or dependencies differ from the OTA vold") + + labels = [label for pattern, label in system_fs.contexts if pattern.fullmatch(str(TARGET))] + if not labels or labels[0] != "u:object_r:vold_exec:s0": + raise VoldModuleError("target /system/bin/vold lacks the expected vold_exec SELinux label") + + # ExtFs reuses the existing image metadata for an existing regular file. + # Do not unlink/recreate the path: that would change its mode/owner/context. + with system_fs.open(str(TARGET), "wb") as stream: + stream.write(binary) + 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", ""), + ) + return ModuleRequirements(boot_images=set(), ext_images={"system"}, 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: + raise VoldModuleError("system image is unavailable for vold injection") + inject_vold( + ext_fs["system"], + 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..114c99db 100644 --- a/src/rom_profiles.sh +++ b/src/rom_profiles.sh @@ -233,6 +233,12 @@ 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_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 +253,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 +320,36 @@ 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_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="$(PYTHONPATH=src python3 - "${PIXENEOS_VOLD_ARTIFACT_DIR}" <<'PY' +import sys +from pathlib import Path +from lineage_vold import _artifact, _sha256 + +binary, _ = _artifact(Path(sys.argv[1])) +print(_sha256(binary)) +PY +)" || 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 +431,13 @@ 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_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..70d99e0f 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,86 @@ 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 ! PYTHONPATH=src python3 - "${artifact_dir}" "${base_build_id}" <<'PY' +import sys +from pathlib import Path +import lineage_vold + +if sys.argv[2] != lineage_vold.BASE_BUILD_ID: + raise SystemExit("selected base build id differs from the pinned Vold source build") +lineage_vold._artifact(Path(sys.argv[1])) +PY + 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 +1182,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_contract_test.py b/tests/lineage_vold_contract_test.py new file mode 100644 index 00000000..dc1c7be6 --- /dev/null +++ b/tests/lineage_vold_contract_test.py @@ -0,0 +1,153 @@ +#!/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 + + +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(" Date: Wed, 21 Oct 2026 23:46:19 +0200 Subject: [PATCH 02/73] fix(vold): preserve selection fingerprints when module is off --- src/ci/selection_variant.sh | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/ci/selection_variant.sh b/src/ci/selection_variant.sh index d4a1f568..6f747bf7 100755 --- a/src/ci/selection_variant.sh +++ b/src/ci/selection_variant.sh @@ -217,14 +217,20 @@ function selection_variant_manifest() { printf '%s\n' \ "module.fsck-sysfs-sepolicy=${SELECTION_MODULE_FSCK_SYSFS_SEPOLICY}" \ - "fsck_sysfs_sepolicy_adapter_sha256=${SELECTION_FSCK_SYSFS_SEPOLICY_ADAPTER_SHA256}" \ - "module.lineage-vold-exfat-timeout=${SELECTION_MODULE_LINEAGE_VOLD_EXFAT_TIMEOUT}" \ - "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_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}" + "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_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 From ee47c457c3416013cd70d773dc187a69d48df8c2 Mon Sep 17 00:00:00 2001 From: 0cwa Date: Wed, 21 Oct 2026 23:46:44 +0200 Subject: [PATCH 03/73] test(vold): cover disabled fingerprint stability and selected digest --- tests/rom_contract_test.sh | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/rom_contract_test.sh b/tests/rom_contract_test.sh index aaad079c..68b32392 100644 --- a/tests/rom_contract_test.sh +++ b/tests/rom_contract_test.sh @@ -170,6 +170,27 @@ test_selection_fingerprint() ( if grep -Eq '^boot_animation(_sha256)?=' "${fingerprint_input}"; then fail "default-off fingerprint must omit boot-animation fields" fi + # Adding the optional vold module must not republish unchanged builds. + if grep -Eq '^(module\.lineage-vold-exfat-timeout|lineage_vold_)' "${fingerprint_input}"; then + fail "disabled source-built vold unexpectedly changed the selection identity" + fi + local fake_vold_digest enabled_vold_manifest field + fake_vold_digest="$(printf "%064d" 0)" + SELECTION_MODULE_LINEAGE_VOLD_EXFAT_TIMEOUT=true + for field in SELECTION_LINEAGE_VOLD_LOCK_SHA256 \ + SELECTION_LINEAGE_VOLD_ADAPTER_SHA256 \ + SELECTION_LINEAGE_VOLD_BUILDER_SHA256 \ + SELECTION_LINEAGE_VOLD_PATCH_SHA256 \ + SELECTION_LINEAGE_VOLD_MANIFEST_SHA256 \ + SELECTION_LINEAGE_VOLD_BINARY_SHA256; do + printf -v "${field}" "%s" "${fake_vold_digest}" + done + enabled_vold_manifest="$(selection_variant_manifest)" || + fail "enabled vold identity serialization failed" + grep -Fxq "module.lineage-vold-exfat-timeout=true" <<<"${enabled_vold_manifest}" || + fail "enabled vold was omitted from the selection identity" + grep -Fxq "lineage_vold_binary_sha256=${fake_vold_digest}" <<<"${enabled_vold_manifest}" || + fail "enabled vold binary digest was omitted from the selection identity" MAGISK[PREINIT]="sda10" ADDITIONALS[ROOT]="true" From f72851c28da563e487f55e56d8f8f4d554414372 Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 00:17:57 +0200 Subject: [PATCH 04/73] fix(vold): verify actual SELinux xattr and preserve exact system-entry metadata --- src/lineage_vold.py | 85 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 65 insertions(+), 20 deletions(-) diff --git a/src/lineage_vold.py b/src/lineage_vold.py index c63db848..e054fb22 100644 --- a/src/lineage_vold.py +++ b/src/lineage_vold.py @@ -214,12 +214,38 @@ def _build_properties(system_fs: Any) -> dict[str, str]: 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, 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) properties = _build_properties(system_fs) - if properties.get("ro.product.device") != "pdx235": + # Modern Android partitions normally advertise ro.product.system.device, + # whereas ro.product.device is resolved by init from several partitions. + device_values = [ + properties[key] + for key in ("ro.product.system.device", "ro.product.device", "ro.build.product") + if key in 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 ( properties.get("ro.lineage.build.version") != "24.0" @@ -228,31 +254,50 @@ def inject_vold(system_fs: Any, artifact_dir: Path, base_build_id: str) -> str: 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] - target_path = system_fs.tree / TARGET.relative_to("/") if len(entries) != 1 or entries[0].file_type != "RegularFile": raise VoldModuleError("target /system/bin/vold filesystem metadata is missing or not regular") - if not entries[0].file_mode & 0o111: + 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") - try: - target_metadata = target_path.lstat() - except OSError as exc: - raise VoldModuleError("target /system/bin/vold is missing from the unpacked system image") from exc - if stat.S_ISLNK(target_metadata.st_mode) or not stat.S_ISREG(target_metadata.st_mode): - raise VoldModuleError("target /system/bin/vold tree path is not a safe regular file") - old_binary = _read_regular_file(target_path, MAX_BINARY_SIZE) - old_identity = _elf_identity(old_binary) - new_identity = _elf_identity(binary) - if old_identity != new_identity: - raise VoldModuleError("source-built vold ELF ABI or dependencies differ from the OTA vold") + 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 labels[0] != "u:object_r:vold_exec:s0": - raise VoldModuleError("target /system/bin/vold lacks the expected vold_exec SELinux label") + 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 reuses the existing image metadata for an existing regular file. - # Do not unlink/recreate the path: that would change its mode/owner/context. - with system_fs.open(str(TARGET), "wb") as stream: - stream.write(binary) + # 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: From 0f1c4ce2666a58d6de0ebbcbdb301b9818713250 Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 00:18:45 +0200 Subject: [PATCH 05/73] test(vold): exercise image injection metadata and SELinux preservation --- tests/lineage_vold_contract_test.py | 68 +++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/tests/lineage_vold_contract_test.py b/tests/lineage_vold_contract_test.py index dc1c7be6..cfb6299c 100644 --- a/tests/lineage_vold_contract_test.py +++ b/tests/lineage_vold_contract_test.py @@ -111,6 +111,74 @@ def test_artifact_requires_full_pinned_provenance_and_arm64_abi(self): with self.assertRaises(lineage_vold.VoldModuleError): lineage_vold._artifact(artifact) + def test_injection_checks_actual_selinux_and_preserves_image_metadata(self): + import re + from types import SimpleNamespace + from unittest.mock import patch + + binary = self.fixture_elf() + with tempfile.TemporaryDirectory() as tmp: + tree = Path(tmp) + (tree / "system" / "bin").mkdir(parents=True) + (tree / "system" / "build.prop").write_text( + "ro.product.system.device=pdx235\n" + "ro.lineage.build.version=24.0\n" + "ro.lineage.version=24.0-20261016-NIGHTLY-pdx235\n" + ) + original = tree / "system" / "bin" / "vold" + original.write_bytes(binary) + original.chmod(0o755) + entry = SimpleNamespace( + path=lineage_vold.TARGET, + source=None, + file_type="RegularFile", + file_mode=0o755, + uid=None, + gid=None, + atime=0, + ctime=0, + mtime=0, + crtime=0, + device_major=None, + device_minor=None, + symlink_target=None, + xattrs={"security.selinux": "u:object_r:vold_exec:s0\0"}, + ) + + class FakeExtFs: + def __init__(self): + self.tree = tree + self.info = SimpleNamespace(entries=[entry]) + self.contexts = [ + (re.compile(r"/system/bin/vold"), "u:object_r:vold_exec:s0") + ] + self.change_metadata_on_write = False + + def open(self, path, flags, mode=None): + if path != "/system/bin/vold" or flags != "wb" or mode != 0o755: + raise AssertionError("vold must overwrite with the original mode") + if self.change_metadata_on_write: + entry.file_mode = 0o644 + return original.open("wb") + + fs = FakeExtFs() + with patch.object(lineage_vold, "_artifact", return_value=(binary, {})): + sha = lineage_vold.inject_vold(fs, tree, lineage_vold.BASE_BUILD_ID) + self.assertEqual(sha, hashlib.sha256(binary).hexdigest()) + self.assertEqual(entry.uid, 0) + self.assertEqual(entry.gid, 0) + self.assertEqual(entry.file_mode, 0o755) + self.assertEqual(entry.xattrs["security.selinux"], "u:object_r:vold_exec:s0\0") + + entry.xattrs["security.selinux"] = "u:object_r:unlabeled:s0" + with self.assertRaisesRegex(lineage_vold.VoldModuleError, "SELinux xattr"): + lineage_vold.inject_vold(fs, tree, lineage_vold.BASE_BUILD_ID) + + entry.xattrs["security.selinux"] = "u:object_r:vold_exec:s0\0" + fs.change_metadata_on_write = True + with self.assertRaisesRegex(lineage_vold.VoldModuleError, "changed filesystem entry metadata"): + lineage_vold.inject_vold(fs, tree, lineage_vold.BASE_BUILD_ID) + def test_helper_module_imports_without_helper_runtime(self): import tempfile From 8a72093a99dd08ab30dfa1db52d76c1fbfa48422 Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 00:20:22 +0200 Subject: [PATCH 06/73] fix(vold): validate Lineage build identity in product partition --- src/lineage_vold.py | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/src/lineage_vold.py b/src/lineage_vold.py index e054fb22..9a2af277 100644 --- a/src/lineage_vold.py +++ b/src/lineage_vold.py @@ -198,8 +198,10 @@ def _artifact(artifact_dir: Path) -> tuple[bytes, dict[str, Any]]: return binary, provenance -def _build_properties(system_fs: Any) -> dict[str, str]: - path = system_fs.tree / "system" / "build.prop" +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: @@ -233,23 +235,24 @@ def _metadata_snapshot(entry: Any) -> tuple[Any, ...]: ) -def inject_vold(system_fs: Any, artifact_dir: Path, base_build_id: str) -> str: +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) - properties = _build_properties(system_fs) + 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 = [ - properties[key] + system_properties[key] for key in ("ro.product.system.device", "ro.product.device", "ro.build.product") - if key in properties + 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 ( - properties.get("ro.lineage.build.version") != "24.0" - or properties.get("ro.lineage.version") != "24.0-20261016-NIGHTLY-pdx235" + 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") @@ -328,7 +331,9 @@ def requirements(self) -> ModuleRequirements: os.environ.get("PIXENEOS_ROM_FAMILY", ""), os.environ.get("PIXENEOS_DEVICE_NAME", ""), ) - return ModuleRequirements(boot_images=set(), ext_images={"system"}, selinux_patching=False) + # 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, @@ -345,10 +350,11 @@ def inject( 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: - raise VoldModuleError("system image is unavailable for vold injection") + 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, ""), ) From cf3df83f265fc20d10b7896bdaae6f441492cbbe Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 00:20:46 +0200 Subject: [PATCH 07/73] test(vold): require exact product-partition Lineage properties --- tests/lineage_vold_contract_test.py | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/tests/lineage_vold_contract_test.py b/tests/lineage_vold_contract_test.py index cfb6299c..41346904 100644 --- a/tests/lineage_vold_contract_test.py +++ b/tests/lineage_vold_contract_test.py @@ -122,9 +122,13 @@ def test_injection_checks_actual_selinux_and_preserves_image_metadata(self): (tree / "system" / "bin").mkdir(parents=True) (tree / "system" / "build.prop").write_text( "ro.product.system.device=pdx235\n" + ) + (tree / "product").mkdir() + (tree / "product" / "build.prop").write_text( "ro.lineage.build.version=24.0\n" "ro.lineage.version=24.0-20261016-NIGHTLY-pdx235\n" ) + product_fs = SimpleNamespace(tree=tree) original = tree / "system" / "bin" / "vold" original.write_bytes(binary) original.chmod(0o755) @@ -163,21 +167,32 @@ def open(self, path, flags, mode=None): fs = FakeExtFs() with patch.object(lineage_vold, "_artifact", return_value=(binary, {})): - sha = lineage_vold.inject_vold(fs, tree, lineage_vold.BASE_BUILD_ID) + sha = lineage_vold.inject_vold(fs, product_fs, tree, lineage_vold.BASE_BUILD_ID) self.assertEqual(sha, hashlib.sha256(binary).hexdigest()) self.assertEqual(entry.uid, 0) self.assertEqual(entry.gid, 0) self.assertEqual(entry.file_mode, 0o755) self.assertEqual(entry.xattrs["security.selinux"], "u:object_r:vold_exec:s0\0") + (tree / "product" / "build.prop").write_text( + "ro.lineage.build.version=24.0\n" + "ro.lineage.version=24.0-20261017-NIGHTLY-pdx235\n" + ) + with self.assertRaisesRegex(lineage_vold.VoldModuleError, "does not match"): + lineage_vold.inject_vold(fs, product_fs, tree, lineage_vold.BASE_BUILD_ID) + (tree / "product" / "build.prop").write_text( + "ro.lineage.build.version=24.0\n" + "ro.lineage.version=24.0-20261016-NIGHTLY-pdx235\n" + ) + entry.xattrs["security.selinux"] = "u:object_r:unlabeled:s0" with self.assertRaisesRegex(lineage_vold.VoldModuleError, "SELinux xattr"): - lineage_vold.inject_vold(fs, tree, lineage_vold.BASE_BUILD_ID) + lineage_vold.inject_vold(fs, product_fs, tree, lineage_vold.BASE_BUILD_ID) entry.xattrs["security.selinux"] = "u:object_r:vold_exec:s0\0" fs.change_metadata_on_write = True with self.assertRaisesRegex(lineage_vold.VoldModuleError, "changed filesystem entry metadata"): - lineage_vold.inject_vold(fs, tree, lineage_vold.BASE_BUILD_ID) + lineage_vold.inject_vold(fs, product_fs, tree, lineage_vold.BASE_BUILD_ID) def test_helper_module_imports_without_helper_runtime(self): import tempfile @@ -206,7 +221,7 @@ def test_helper_module_imports_without_helper_runtime(self): self.assertEqual(loaded.LineageVoldMod.NAME, lineage_vold.MODULE_NAME) self.assertEqual( loaded.LineageVoldMod.requirements(loaded.LineageVoldMod()).ext_images, - {"system"}, + {"system", "product"}, ) finally: sys.path.remove(tmp) From 7d37f7ae44f3c9ee3c487ba8916427f78909a7db Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 00:22:08 +0200 Subject: [PATCH 08/73] feat(vold): require independently approved SHA before published replacement --- src/ci/check_lineage_vold_artifact.py | 73 +++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 src/ci/check_lineage_vold_artifact.py 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()) From 55d4f4b193df10470c74a557e16dcfda3a3bf316 Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 00:22:25 +0200 Subject: [PATCH 09/73] policy(vold): leave publication digest unapproved until real validation --- locks/lineage-vold-exfat-timeout.json | 1 + 1 file changed, 1 insertion(+) diff --git a/locks/lineage-vold-exfat-timeout.json b/locks/lineage-vold-exfat-timeout.json index 48b42269..a74aa4fe 100644 --- a/locks/lineage-vold-exfat-timeout.json +++ b/locks/lineage-vold-exfat-timeout.json @@ -4,6 +4,7 @@ "rom_family": "lineageos", "device": "pdx235", "base_build_id": "lineage-24.0-20261016-nightly-pdx235-signed", + "approved_binary_sha256": "", "manifest": { "url": "https://mirrorbits.lineageos.org/full/pdx235/20261016/build-manifest.xml", "path": "locks/lineage-pdx235-20261016-build-manifest.xml", From 65fbb9192832afccb2a3d8e72ed0af43576e1cd1 Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 00:22:43 +0200 Subject: [PATCH 10/73] fix(vold): bind OTA source identity and approved hash before selection --- src/rom_profiles.sh | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/rom_profiles.sh b/src/rom_profiles.sh index 114c99db..f6b715cd 100644 --- a/src/rom_profiles.sh +++ b/src/rom_profiles.sh @@ -335,15 +335,11 @@ function module_selection_fingerprint() { lineage_vold_builder_digest="$(_locked_input_digest src/ci/build_lineage_vold.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="$(PYTHONPATH=src python3 - "${PIXENEOS_VOLD_ARTIFACT_DIR}" <<'PY' -import sys -from pathlib import Path -from lineage_vold import _artifact, _sha256 - -binary, _ = _artifact(Path(sys.argv[1])) -print(_sha256(binary)) -PY -)" || 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 From 5695490901055f1116de2d4f0d8fa113efcdd41a Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 00:23:05 +0200 Subject: [PATCH 11/73] fix(vold): reverify approved artifact at injection time --- src/util_functions.sh | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/src/util_functions.sh b/src/util_functions.sh index 70d99e0f..84160856 100755 --- a/src/util_functions.sh +++ b/src/util_functions.sh @@ -749,16 +749,11 @@ function prepare_lineage_vold_exfat_timeout_module() { echo "Error: the Vold adapter or pinned helper registry path is missing or unsafe." >&2 return 1 fi - if ! PYTHONPATH=src python3 - "${artifact_dir}" "${base_build_id}" <<'PY' -import sys -from pathlib import Path -import lineage_vold - -if sys.argv[2] != lineage_vold.BASE_BUILD_ID: - raise SystemExit("selected base build id differs from the pinned Vold source build") -lineage_vold._artifact(Path(sys.argv[1])) -PY - then + 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; echo "Error: the source-built vold artifact failed provenance or ELF validation." >&2 return 1 fi From 7e69e135081a3db6e9b0543bf5a913fb3c305ea4 Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 00:23:18 +0200 Subject: [PATCH 12/73] fix(vold): close artifact verifier shell condition --- src/util_functions.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/util_functions.sh b/src/util_functions.sh index 84160856..5eea8714 100755 --- a/src/util_functions.sh +++ b/src/util_functions.sh @@ -753,7 +753,7 @@ function prepare_lineage_vold_exfat_timeout_module() { --artifact-dir "${artifact_dir}" \ --scope "${OUTPUT_SCOPE}" \ --base-build-id "${base_build_id}" \ - --source-target "${GRAPHENEOS[OTA_TARGET]:-}" >/dev/null; + --source-target "${GRAPHENEOS[OTA_TARGET]:-}" >/dev/null; then echo "Error: the source-built vold artifact failed provenance or ELF validation." >&2 return 1 fi From 345c0f50ce74eb644d2eec81d542e4c7469e7fee Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 00:23:55 +0200 Subject: [PATCH 13/73] fix(vold): fingerprint vetted verifier code for selected module --- src/ci/selection_variant.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/ci/selection_variant.sh b/src/ci/selection_variant.sh index 6f747bf7..677d9bdd 100755 --- a/src/ci/selection_variant.sh +++ b/src/ci/selection_variant.sh @@ -52,6 +52,7 @@ function selection_variant_manifest() { 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 @@ -101,6 +102,7 @@ function selection_variant_manifest() { 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 @@ -161,6 +163,7 @@ function selection_variant_manifest() { 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 @@ -173,6 +176,7 @@ function selection_variant_manifest() { 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 @@ -227,6 +231,7 @@ function selection_variant_manifest() { "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}" From 05f587513d33088139d83a4d26525149e6425886 Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 00:24:07 +0200 Subject: [PATCH 14/73] fix(vold): hash publication verifier and gate the selected source --- src/rom_profiles.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/rom_profiles.sh b/src/rom_profiles.sh index f6b715cd..00687608 100644 --- a/src/rom_profiles.sh +++ b/src/rom_profiles.sh @@ -236,6 +236,7 @@ function module_selection_fingerprint() { 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" @@ -333,6 +334,7 @@ function module_selection_fingerprint() { 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 \ @@ -431,6 +433,7 @@ function module_selection_fingerprint() { 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}" From 602fd354b96a64189bc9520dc4b33c5f44508c2c Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 00:24:17 +0200 Subject: [PATCH 15/73] test(vold): cover published verifier SHA in module selection --- tests/rom_contract_test.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/rom_contract_test.sh b/tests/rom_contract_test.sh index 68b32392..426c7b19 100644 --- a/tests/rom_contract_test.sh +++ b/tests/rom_contract_test.sh @@ -180,6 +180,7 @@ test_selection_fingerprint() ( 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 From 3af29b3e8dae954d1f7e5e6a0b357fc6b61fd3b9 Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 00:24:41 +0200 Subject: [PATCH 16/73] test(vold): reject unapproved publication and mismatched source OTA --- tests/lineage_vold_contract_test.py | 52 +++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/tests/lineage_vold_contract_test.py b/tests/lineage_vold_contract_test.py index 41346904..e04dd25b 100644 --- a/tests/lineage_vold_contract_test.py +++ b/tests/lineage_vold_contract_test.py @@ -17,6 +17,7 @@ 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 class LineageVoldContractTest(unittest.TestCase): @@ -194,6 +195,57 @@ def open(self, path, flags, mode=None): with self.assertRaisesRegex(lineage_vold.VoldModuleError, "changed filesystem entry metadata"): lineage_vold.inject_vold(fs, product_fs, tree, lineage_vold.BASE_BUILD_ID) + def test_published_module_requires_independent_binary_approval(self): + from unittest.mock import patch + + lock = json.loads((ROOT / "locks/lineage-vold-exfat-timeout.json").read_text()) + binary = self.fixture_elf() + digest = hashlib.sha256(binary).hexdigest() + self.assertEqual(lock["approved_binary_sha256"], "") + check_lineage_vold_artifact.require_release_approval( + lock, digest, "local-unpublished" + ) + with self.assertRaisesRegex(lineage_vold.VoldModuleError, "blocked"): + check_lineage_vold_artifact.require_release_approval(lock, digest, "published") + with self.assertRaises(lineage_vold.VoldModuleError): + check_lineage_vold_artifact.require_release_approval(lock, digest, "shared") + approved_lock = {**lock, "approved_binary_sha256": digest} + check_lineage_vold_artifact.require_release_approval( + approved_lock, digest, "published" + ) + with self.assertRaisesRegex(lineage_vold.VoldModuleError, "approved publication digest"): + check_lineage_vold_artifact.require_release_approval( + approved_lock, "0" * 64, "published" + ) + + with ( + patch.object( + check_lineage_vold_artifact, "load_contract", + return_value=(lock, b"", b""), + ), + patch.object( + check_lineage_vold_artifact, "_artifact", + return_value=(binary, {}), + ), + ): + self.assertEqual( + check_lineage_vold_artifact.verify( + Path("/unused"), "local-unpublished", + lineage_vold.BASE_BUILD_ID, lineage_vold.BASE_BUILD_ID, + ), + digest, + ) + with self.assertRaisesRegex(lineage_vold.VoldModuleError, "does not match"): + check_lineage_vold_artifact.verify( + Path("/unused"), "local-unpublished", + lineage_vold.BASE_BUILD_ID, "lineage-24.0-20261017-nightly-pdx235-signed", + ) + with self.assertRaisesRegex(lineage_vold.VoldModuleError, "blocked"): + check_lineage_vold_artifact.verify( + Path("/unused"), "published", + lineage_vold.BASE_BUILD_ID, lineage_vold.BASE_BUILD_ID, + ) + def test_helper_module_imports_without_helper_runtime(self): import tempfile From 9f029c04dac83508ca3caa8fcc69bc0c9a7f88aa Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 00:26:31 +0200 Subject: [PATCH 17/73] policy(vold): reserve digest-pinned staging release coordinates --- locks/lineage-vold-exfat-timeout.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/locks/lineage-vold-exfat-timeout.json b/locks/lineage-vold-exfat-timeout.json index a74aa4fe..5b8cff4c 100644 --- a/locks/lineage-vold-exfat-timeout.json +++ b/locks/lineage-vold-exfat-timeout.json @@ -5,6 +5,13 @@ "device": "pdx235", "base_build_id": "lineage-24.0-20261016-nightly-pdx235-signed", "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", From b14e77cd93b4e5419dacf655a143d2e83a56b903 Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 00:27:11 +0200 Subject: [PATCH 18/73] feat(vold): fetch only cryptographically pinned staging release artifacts --- src/ci/fetch_lineage_vold_artifact.py | 94 +++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 src/ci/fetch_lineage_vold_artifact.py diff --git a/src/ci/fetch_lineage_vold_artifact.py b/src/ci/fetch_lineage_vold_artifact.py new file mode 100644 index 00000000..b6d8a351 --- /dev/null +++ b/src/ci/fetch_lineage_vold_artifact.py @@ -0,0 +1,94 @@ +#!/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 + +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()) From 0e7398d53a83d565921a68839ceb6cc08108df2b Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 00:27:40 +0200 Subject: [PATCH 19/73] fix(vold): import standalone artifact verifier without path side effects --- src/ci/fetch_lineage_vold_artifact.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/ci/fetch_lineage_vold_artifact.py b/src/ci/fetch_lineage_vold_artifact.py index b6d8a351..cc95266e 100644 --- a/src/ci/fetch_lineage_vold_artifact.py +++ b/src/ci/fetch_lineage_vold_artifact.py @@ -17,6 +17,9 @@ 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 From e67b3e5cf5f48e4e110702c7bf6a227f4e44c220 Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 00:28:49 +0200 Subject: [PATCH 20/73] feat(vold): load explicit scoped schedule selector; reject GrapheneOS --- src/ci/load_schedule_definition.sh | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/ci/load_schedule_definition.sh b/src/ci/load_schedule_definition.sh index 27a850e7..ad407071 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 + case "${root_mode}" in rootless|magisk|both) ;; *) @@ -96,6 +108,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}" @@ -119,6 +132,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}" From f96dac1039a9066dde73cabaeadb56d72062f922 Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 00:28:58 +0200 Subject: [PATCH 21/73] policy(vold): keep scheduled production disabled pending pinned binary qualification --- .github/schedules/grapheneos-shiba.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/schedules/grapheneos-shiba.toml b/.github/schedules/grapheneos-shiba.toml index 56d4d508..11bc98f3 100644 --- a/.github/schedules/grapheneos-shiba.toml +++ b/.github/schedules/grapheneos-shiba.toml @@ -18,4 +18,5 @@ MAGISK_PREINIT = "sda10" 'ADDITIONALS[BOOT_ANIMATION]' = true 'ADDITIONALS[MAS_COMPATIBLE_SEPOLICY]' = false 'ADDITIONALS[FSCK_SYSFS_SEPOLICY]' = false +'ADDITIONALS[LINEAGE_VOLD_EXFAT_TIMEOUT]' = false # pending independent binary qualification FORCE_UPDATE = false From 1f6a7c1faa14177e64517d333c9b278bd4abf93a Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 00:29:00 +0200 Subject: [PATCH 22/73] policy(vold): keep scheduled production disabled pending pinned binary qualification --- .github/schedules/lineageos-pdx235.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/schedules/lineageos-pdx235.toml b/.github/schedules/lineageos-pdx235.toml index f5bfff21..ae1b0bdd 100644 --- a/.github/schedules/lineageos-pdx235.toml +++ b/.github/schedules/lineageos-pdx235.toml @@ -20,4 +20,5 @@ MAGISK_PREINIT = "sda47" 'ADDITIONALS[MAS_COMPATIBLE_SEPOLICY]' = true 'ADDITIONALS[EXFATPROGS_FSCK]' = true 'ADDITIONALS[FSCK_SYSFS_SEPOLICY]' = true +'ADDITIONALS[LINEAGE_VOLD_EXFAT_TIMEOUT]' = false # pending independent binary qualification FORCE_UPDATE = false From 20168bad1ee8a310f9e4edb1f58ad5b75a411e20 Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 00:29:28 +0200 Subject: [PATCH 23/73] feat(vold): wire approved artifact through scheduled and manual Lineage builds --- .github/workflows/release-lineage.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) 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 }} From 440ec87df02643d8db69ea06bff128261dd40693 Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 00:29:50 +0200 Subject: [PATCH 24/73] feat(vold): fetch independently pinned artifact in reusable build; scope GrapheneOS out --- .github/workflows/build-rom.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/.github/workflows/build-rom.yml b/.github/workflows/build-rom.yml index 50cee754..fed0906e 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}" == 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) && @@ -220,6 +231,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 From 8d0d80636b4c91e4bd78912cd77c131503912e42 Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 00:30:32 +0200 Subject: [PATCH 25/73] test(vold): enforce digest-pinned staging asset download and fail-closed cleanup --- tests/lineage_vold_contract_test.py | 48 +++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/lineage_vold_contract_test.py b/tests/lineage_vold_contract_test.py index e04dd25b..be7c7fd4 100644 --- a/tests/lineage_vold_contract_test.py +++ b/tests/lineage_vold_contract_test.py @@ -18,6 +18,7 @@ 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): @@ -246,6 +247,53 @@ def test_published_module_requires_independent_binary_approval(self): lineage_vold.BASE_BUILD_ID, lineage_vold.BASE_BUILD_ID, ) + def test_staged_release_is_unusable_until_both_assets_are_pinned(self): + from unittest.mock import patch + + lock = json.loads((ROOT / "locks/lineage-vold-exfat-timeout.json").read_text()) + with self.assertRaisesRegex(ValueError, "release tag is not approved"): + fetch_lineage_vold_artifact.distribution_contract(lock) + + binary = self.fixture_elf() + metadata = b'{"module":"lineage-vold-exfat-timeout"}\n' + lock["approved_binary_sha256"] = hashlib.sha256(binary).hexdigest() + lock["artifact_distribution"]["release_tag"] = "vold-pdx235-20261016-reviewed" + lock["artifact_distribution"]["provenance_sha256"] = hashlib.sha256(metadata).hexdigest() + self.assertEqual( + fetch_lineage_vold_artifact.distribution_contract(lock)["binary_asset"], "vold" + ) + + def fake_download(argv, check): + self.assertTrue(check) + self.assertEqual(argv[:4], ["gh", "release", "download", "vold-pdx235-20261016-reviewed"]) + destination = Path(argv[argv.index("--dir") + 1]) + (destination / "vold").write_bytes(binary) + (destination / "provenance.json").write_bytes(metadata) + + with tempfile.TemporaryDirectory() as tmp: + artifact = Path(tmp) / "artifact" + with ( + patch.object(fetch_lineage_vold_artifact, "load_contract", + return_value=(lock, b"", b"")), + patch.object(fetch_lineage_vold_artifact.subprocess, "run", + side_effect=fake_download), + patch.object(fetch_lineage_vold_artifact, "verify", + return_value=lock["approved_binary_sha256"]), + ): + self.assertEqual( + fetch_lineage_vold_artifact.fetch(artifact), lock["approved_binary_sha256"] + ) + self.assertTrue((artifact / "vold").is_file()) + # A destination containing a previous artifact cannot be silently reused. + with self.assertRaisesRegex(ValueError, "destination must be absent"): + fetch_lineage_vold_artifact.fetch(artifact) + + different = Path(tmp) / "tampered" + lock["artifact_distribution"]["provenance_sha256"] = "0" * 64 + with self.assertRaisesRegex(ValueError, "provenance digest"): + fetch_lineage_vold_artifact.fetch(different) + self.assertFalse(different.exists()) + def test_helper_module_imports_without_helper_runtime(self): import tempfile From 52a17909dddc4a1493e98d580a7cdd7607b99841 Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 00:31:43 +0200 Subject: [PATCH 26/73] test(vold): scheduled defaults and selector forwarding stay explicit --- tests/scheduled_build_definitions_test.sh | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/scheduled_build_definitions_test.sh b/tests/scheduled_build_definitions_test.sh index c645f4d5..cfecdeca 100644 --- a/tests/scheduled_build_definitions_test.sh +++ b/tests/scheduled_build_definitions_test.sh @@ -41,6 +41,8 @@ check_definition() { [[ "${expected_family}" != lineageos ]] || expected_exfat=true [[ "$(toml_resolve_value exfatprogs_fsck false)" == "${expected_exfat}" ]] || fail "${file}: wrong exfatprogs fsck selection" + [[ "$(toml_resolve_value lineage_vold_exfat_timeout '')" == false ]] || + fail "${file}: source-built vold must remain disabled until approved" [[ "$(toml_resolve_value force_update '')" == false ]] || fail "${file}: scheduled force update must default to false" [[ "$(toml_resolve_value shizuku false)" == false ]] || @@ -100,6 +102,10 @@ check_loader() { fail "${file}: loader exported wrong exfatprogs fsck selection" grep -Fxq "ADDITIONALS_FSCK_SYSFS_SEPOLICY=${expected_fsck_sysfs_sepolicy}" "${env_file}" || fail "${file}: loader exported wrong fsck sysfs-SEPolicy selection" + grep -Fxq "lineage_vold_exfat_timeout=false" "${output_file}" || + fail "${file}: schedule loader unexpectedly enabled unqualified vold" + grep -Fxq "ADDITIONALS_LINEAGE_VOLD_EXFAT_TIMEOUT=false" "${env_file}" || + fail "${file}: schedule loader exported unexpected vold selection" grep -Fxq 'ADDITIONALS_DISABLE_SYSTEM_UPDATER=true' "${env_file}" || fail "${file}: loader did not export stock updater removal" grep -Fxq 'ADDITIONALS_BOOT_ANIMATION=true' "${env_file}" || @@ -139,6 +145,8 @@ grep -Fq 'needs.schedule_config.outputs.disable_system_updater' .github/workflow fail "LineageOS scheduled updater removal is not forwarded from the definition" grep -Fq "needs.schedule_config.outputs.exfatprogs_fsck == 'true'" .github/workflows/release-lineage.yml || fail "LineageOS scheduled exfatprogs selection is not forwarded to the build" +grep -Fq "needs.schedule_config.outputs.lineage_vold_exfat_timeout == 'true'" .github/workflows/release-lineage.yml || + fail "LineageOS schedule does not route its opt-in vold module to the builder" grep -Fq 'check_existing_pair.sh' .github/workflows/release.yml || fail "GrapheneOS paired schedule is not protected by paired existing-build preflight" From 1bd0eb680beeed2b2205dac4b072d1090c71b9d1 Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 00:32:09 +0200 Subject: [PATCH 27/73] test(vold): reject tampered GrapheneOS scheduled selection --- tests/scheduled_build_definitions_test.sh | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/scheduled_build_definitions_test.sh b/tests/scheduled_build_definitions_test.sh index cfecdeca..1d0b0923 100644 --- a/tests/scheduled_build_definitions_test.sh +++ b/tests/scheduled_build_definitions_test.sh @@ -160,6 +160,26 @@ else fail "LineageOS schedule jobs are missing" fi +# A tampered GrapheneOS schedule may not select the Lineage-only vold daemon. +vold_tampered="$(mktemp)" +vold_output="$(mktemp)" +vold_env="$(mktemp)" +python3 - "${vold_tampered}" <<'PYVOLD' +from pathlib import Path +import sys + +source = Path(".github/schedules/grapheneos-shiba.toml").read_text() +old = "'ADDITIONALS[LINEAGE_VOLD_EXFAT_TIMEOUT]' = false" +assert source.count(old) == 1 +Path(sys.argv[1]).write_text(source.replace(old, old.replace("false", "true"))) +PYVOLD +if SCHEDULE_DEFINITION="${vold_tampered}" GITHUB_OUTPUT="${vold_output}" \ + GITHUB_ENV="${vold_env}" EXPECTED_ROM_FAMILY=grapheneos \ + bash src/ci/load_schedule_definition.sh >/dev/null 2>&1; then + fail "GrapheneOS schedule accepted the Lineage-only vold module" +fi +rm -f -- "${vold_tampered}" "${vold_output}" "${vold_env}" + echo "scheduled build definition tests passed" grep -Fq "needs.schedule_config.outputs.microg == 'true'" .github/workflows/release-lineage.yml || From b8fd488ca1ada0622219fa68729bb0cac90ca97a Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 00:34:31 +0200 Subject: [PATCH 28/73] docs(vold): document product properties and artifact qualification to release --- docs/lineage-vold-exfat-timeout.md | 69 +++++++++++++++++++++++++----- 1 file changed, 59 insertions(+), 10 deletions(-) diff --git a/docs/lineage-vold-exfat-timeout.md b/docs/lineage-vold-exfat-timeout.md index cfc63c10..ef419f6a 100644 --- a/docs/lineage-vold-exfat-timeout.md +++ b/docs/lineage-vold-exfat-timeout.md @@ -48,26 +48,75 @@ dynamic dependencies before injection. 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. The ModOS setup must set +`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 opt-in and is not enabled in the scheduled build or release -workflow. Build the artifact first, then set +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 `ro.product.device`, `ro.lineage.build.version`, -and the exact `ro.lineage.version` from the pinned source snapshot. It requires -one regular executable `/system/bin/vold`, the `vold_exec` SELinux label, and -the same AArch64 interpreter and dependencies as the original OTA binary. It -writes bytes through the existing ExtFs file entry so its image mode, owner, -and SELinux metadata stay attached to the existing path. A failed check stops -the build before a patched OTA is emitted. +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.** CI exercises synthetic +ELF and filesystem fixtures; a full 1,243-project source sync, Android `m vold` +build, authenticated OTA integration and real-device boot/exFAT checks remain +necessary. Do not set the scheduled default to true or force-publish it yet. + +1. On a suitably provisioned, isolated Linux build host, run the pinned + `sync` and `build` commands above. Retain the generated `vold` and + `provenance.json` without editing either. +2. Validate them against the authentic + `lineage-24.0-20261016-nightly-pdx235-signed` source and run a + **nonpublishing** rootless/Magisk OTA qualification. Confirm both images + boot, vold starts, encrypted storage mounts, and exFAT fsck behaves as + expected; preserve test results. This is not satisfied by unit-test + fixtures. +3. Stage the exact `vold` and `provenance.json` as two independent assets + in a **separate non-OTA release** in `0cwa/ModOS`, using a tag beginning + with `vold-pdx235-20261016-`. Record both + `sha256sum vold provenance.json` values and verify the staging upload. +4. Only after binary, OTA and device qualification, commit the independent + digest approval in `approved_binary_sha256` and the + `artifact_distribution.release_tag` and + `artifact_distribution.provenance_sha256` in the source lock. + `src/ci/fetch_lineage_vold_artifact.py` downloads only this pair and + independently checks its bytes; the scheduled preflight and actual build + both verify it again. +5. Enable `ADDITIONALS[LINEAGE_VOLD_EXFAT_TIMEOUT]` only on the LineageOS + pdx235 schedule, run full CI, then intentionally force-publish and confirm + the resulting rootless/Magisk outputs and update delivery. + +**Nightly rotation:** This lock and artifact intentionally identify *only* +the 2026-10-16 nightly. A newer official LineageOS OTA will fail closed +rather than combining an outdated vold binary with a different source. To +keep future scheduled builds enabled, obtain, qualify and review a fresh +source-locked artifact for each new published nightly before rolling over +the schedule. Never silently reuse the previous binary. + +GrapheneOS remains excluded by explicit workflow and schedule guards. From 43a2cbf38f4a97802de3171f0d955a8ff233809e Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 00:35:01 +0200 Subject: [PATCH 29/73] docs(vold): clarify release staging, qualification and disabled default --- README.md | 38 +++++++++++++++++++++----------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 3daa63a2..d8d121a3 100644 --- a/README.md +++ b/README.md @@ -296,24 +296,28 @@ package URL, size, and SHA-256, but do not repeat the APT signature check. The optional `lineage-vold-exfat-timeout` module replaces `/system/bin/vold` with a binary built from the complete, hash-pinned LineageOS pdx235 source -manifest. It gives exFAT fsck a 120-second limit; the existing timeout for -other filesystems remains 45 seconds. This option is disabled by default and -is not selected in scheduled builds. Build the artifact explicitly with -`src/ci/build_lineage_vold.py` as documented in +manifest. It gives exFAT fsck a 120-second limit; other filesystem timeouts +remain 45 seconds. The manual and scheduled selectors are wired up but are +**disabled by default**; no binary has yet been independently qualified or +approved for release. + +For local qualification, run `src/ci/build_lineage_vold.py` as documented in [`docs/lineage-vold-exfat-timeout.md`](docs/lineage-vold-exfat-timeout.md), -then set `PIXENEOS_VOLD_ARTIFACT_DIR` and -`PIXENEOS_VOLD_BASE_BUILD_ID=lineage-24.0-20261016-nightly-pdx235-signed` -before selecting the module. The OTA build validates source and binary -provenance, AArch64 ABI and dynamic dependencies, exact base OTA identity, and -the existing `vold_exec` SELinux label before replacing bytes in the existing -filesystem entry. - -The pinned upstream `android_system_vold` project is Apache-2.0 licensed; the -source patch is limited to the exFAT timeout constant and its call site. The -full LineageOS source sync and Android binary build are intentionally explicit -and were not run by CI. The binary must be built and checked on a suitable -Linux host before use; this module remains absent from release workflows and -scheduled builds until that build path is operationally provisioned. +then provide `PIXENEOS_VOLD_ARTIFACT_DIR` and the exact +`PIXENEOS_VOLD_BASE_BUILD_ID=lineage-24.0-20261016-nightly-pdx235-signed`. +The adapter validates exact product/system properties, the AArch64 ELF ABI and +dependency list, root ownership, actual `vold_exec` SELinux xattr, and unchanged +filesystem entry metadata. A public OTA additionally requires separately +reviewed SHA-256 pins for both the vold binary and staging provenance. The +`src/ci/fetch_lineage_vold_artifact.py` transport fails closed until those +pins and a dedicated staging-release tag are committed to the source lock. + +The upstream `android_system_vold` patch changes only the exFAT timeout. +The full LineageOS source sync, Android `m vold` binary build and device +qualification have **not** been completed; no production selection or +force-publish is authorized merely by passing CI fixtures. The binary is +pinned to the 2026-10-16 nightly and must be independently rebuilt and +requalified for subsequent LineageOS nightlies. ### Custom boot animation (enabled by default) From e69628dcb5e98b5358cf6a5bea8d211b9c3671ac Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 00:40:26 +0200 Subject: [PATCH 30/73] test(vold): enforce fail-closed workflow transport and GrapheneOS isolation --- tests/lineage_vold_workflow_contract_test.py | 91 ++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 tests/lineage_vold_workflow_contract_test.py diff --git a/tests/lineage_vold_workflow_contract_test.py b/tests/lineage_vold_workflow_contract_test.py new file mode 100644 index 00000000..84e8df6a --- /dev/null +++ b/tests/lineage_vold_workflow_contract_test.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Static release-routing gates for the source-built pdx235 vold module.""" + +import json +from pathlib import Path +import re +import tomllib +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +LINEAGE = ROOT / ".github/workflows/release-lineage.yml" +BUILD = ROOT / ".github/workflows/build-rom.yml" +GRAPHENE = ROOT / ".github/workflows/release.yml" +LOADER = ROOT / "src/ci/load_schedule_definition.sh" +PROFILE = ROOT / "src/rom_profiles.sh" +PREP = ROOT / "src/util_functions.sh" +LOCK = ROOT / "locks/lineage-vold-exfat-timeout.json" + + +class VoldWorkflowContractTest(unittest.TestCase): + def test_both_schedules_are_explicit_and_grapheneos_is_off(self): + for name, expected_device, expected_family in ( + ("lineageos-pdx235.toml", "pdx235", "lineageos"), + ("grapheneos-shiba.toml", "shiba", "grapheneos"), + ): + schedule = tomllib.loads((ROOT / ".github/schedules" / name).read_text()) + self.assertEqual(schedule["device"]["DEVICE_NAME"], expected_device) + self.assertEqual(schedule["device"]["ROM_FAMILY"], expected_family) + self.assertIs( + schedule["build"]["ADDITIONALS[LINEAGE_VOLD_EXFAT_TIMEOUT]"], False + ) + + self.assertIn("rom_family", LOADER.read_text()) + self.assertIn("lineage_vold_exfat_timeout", LOADER.read_text()) + self.assertIn("Source-built vold is supported only for LineageOS pdx235", LOADER.read_text()) + + def test_manual_and_scheduled_builds_fetch_same_approved_artifact(self): + lineage, build, graphene = ( + LINEAGE.read_text(), BUILD.read_text(), GRAPHENE.read_text() + ) + self.assertIn("lineage-vold-exfat-timeout:", lineage) + self.assertIn("lineage-vold-exfat-timeout:", build) + self.assertIn("inputs.lineage-vold-exfat-timeout", lineage) + self.assertIn("needs.schedule_config.outputs.lineage_vold_exfat_timeout == 'true'", lineage) + self.assertIn("inputs.lineage-vold-exfat-timeout", build) + self.assertIn("ADDITIONALS_LINEAGE_VOLD_EXFAT_TIMEOUT:", build) + self.assertNotIn("lineage-vold-exfat-timeout", graphene) + self.assertNotIn("LINEAGE_VOLD_EXFAT_TIMEOUT", graphene) + + staged = "src/ci/fetch_lineage_vold_artifact.py" + self.assertIn(staged, lineage) + self.assertIn(staged, build) + self.assertLess( + lineage.index("Download approved vold artifact for scheduled preflight"), + lineage.index("Check for an already-published matching OTA pair"), + ) + self.assertLess( + build.index("Download approved source-built vold artifact"), + build.index("Enforce profile output policy"), + ) + self.assertIn("PIXENEOS_VOLD_BASE_BUILD_ID=lineage-24.0-20261016-nightly-pdx235-signed", lineage) + self.assertIn("PIXENEOS_VOLD_BASE_BUILD_ID=lineage-24.0-20261016-nightly-pdx235-signed", build) + + def test_publication_cannot_inherit_self_reported_provenance_only(self): + lock = json.loads(LOCK.read_text()) + profile = PROFILE.read_text() + prep = PREP.read_text() + self.assertEqual(lock["device"], "pdx235") + self.assertEqual(lock["rom_family"], "lineageos") + self.assertEqual(lock["base_build_id"], "lineage-24.0-20261016-nightly-pdx235-signed") + for script in (profile, prep): + self.assertIn("src/ci/check_lineage_vold_artifact.py", script) + self.assertIn("--scope", script) + self.assertIn("--source-target", script) + + approved = lock.get("approved_binary_sha256", "") + tag = lock.get("artifact_distribution", {}).get("release_tag", "") + if approved or tag: + self.assertRegex(approved, r"^[0-9a-f]{64}$") + self.assertRegex(tag, r"^vold-pdx235-20261016-[A-Za-z0-9_.-]+$") + self.assertRegex( + lock["artifact_distribution"]["provenance_sha256"], + r"^[0-9a-f]{64}$", + ) + else: + self.assertEqual(lock["artifact_distribution"]["provenance_sha256"], "") + + +if __name__ == "__main__": + unittest.main() From 55bdbacc92827ca45d9a90826052c24a68ce41fb Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 00:40:37 +0200 Subject: [PATCH 31/73] ci(vold): verify workflow routing and independent staging approval gates --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 861fb9cc..c434e8d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,6 +88,9 @@ jobs: - name: Test source-built Vold module contract run: python3 tests/lineage_vold_contract_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 From a588d26d8430fcde4943e08cde161a9c581a0bc3 Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 00:46:33 +0200 Subject: [PATCH 32/73] merge(main): preserve GrapheneOS isolation regressions alongside vold tests --- tests/rom_contract_test.sh | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/rom_contract_test.sh b/tests/rom_contract_test.sh index 426c7b19..a06d8376 100644 --- a/tests/rom_contract_test.sh +++ b/tests/rom_contract_test.sh @@ -355,6 +355,29 @@ test_fsck_sysfs_sepolicy_is_pdx235_scoped_and_fingerprinted() ( fi ) +# GrapheneOS builds must fail closed even if a caller explicitly requests +# a pdx235-only patch through the reusable workflow or local configuration. +test_grapheneos_rejects_pdx235_fsck_modules() ( + local device module + + load_contract + set_selection_fixture + ROM_FAMILY="grapheneos" + ADDITIONALS[EXFATPROGS_FSCK]="false" + ADDITIONALS[FSCK_SYSFS_SEPOLICY]="false" + + for device in shiba pdx235; do + DEVICE_NAME="${device}" + for module in EXFATPROGS_FSCK FSCK_SYSFS_SEPOLICY; do + ADDITIONALS["${module}"]="true" + if module_selection_fingerprint >/dev/null 2>&1; then + fail "GrapheneOS/${device} unexpectedly accepted pdx235-only ${module}" + fi + ADDITIONALS["${module}"]="false" + done + done +) + test_output_filename_contains_fingerprint() ( local full @@ -480,6 +503,7 @@ test_unknown_rom_fails_closed test_invalid_compatible_sepolicy_fails_closed test_selection_fingerprint test_fsck_sysfs_sepolicy_is_pdx235_scoped_and_fingerprinted +test_grapheneos_rejects_pdx235_fsck_modules test_output_filename_contains_fingerprint test_output_policy From 36344dde46641783c512396891486286813412e5 Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 00:46:37 +0200 Subject: [PATCH 33/73] merge(main): preserve GrapheneOS isolation regressions alongside vold tests --- tests/scheduled_build_definitions_test.sh | 43 +++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/scheduled_build_definitions_test.sh b/tests/scheduled_build_definitions_test.sh index 1d0b0923..4056637a 100644 --- a/tests/scheduled_build_definitions_test.sh +++ b/tests/scheduled_build_definitions_test.sh @@ -39,6 +39,8 @@ check_definition() { fail "${file}: wrong fsck sysfs-SEPolicy selection" local expected_exfat=false [[ "${expected_family}" != lineageos ]] || expected_exfat=true + toml_config_has exfatprogs_fsck || + fail "${file}: exfatprogs fsck selection must be explicitly declared" [[ "$(toml_resolve_value exfatprogs_fsck false)" == "${expected_exfat}" ]] || fail "${file}: wrong exfatprogs fsck selection" [[ "$(toml_resolve_value lineage_vold_exfat_timeout '')" == false ]] || @@ -124,6 +126,47 @@ check_loader() { check_loader .github/schedules/grapheneos-shiba.toml shiba grapheneos check_loader .github/schedules/lineageos-pdx235.toml pdx235 lineageos +# The loader must reject a tampered GrapheneOS schedule before exporting any +# build inputs. Test each forbidden switch using an otherwise-valid definition. +check_forbidden_grapheneos_module() { + local module="${1}" fixture output_file env_file error_file + local old="'ADDITIONALS[${module}]' = false" + local new="'ADDITIONALS[${module}]' = true" + fixture="$(mktemp --suffix=.toml)" + output_file="$(mktemp)" + env_file="$(mktemp)" + error_file="$(mktemp)" + cp .github/schedules/grapheneos-shiba.toml "${fixture}" + check_loader "${fixture}" shiba grapheneos + grep -Fxq "${old}" "${fixture}" || fail "missing GrapheneOS baseline: ${module}" + sed -i "s/^'ADDITIONALS\\[${module}\\]' = false$/'ADDITIONALS[${module}]' = true/" "${fixture}" + grep -Fxq "${new}" "${fixture}" || fail "could not activate ${module} in fixture" + + if GITHUB_OUTPUT="${output_file}" GITHUB_ENV="${env_file}" \ + SCHEDULE_DEFINITION="${fixture}" EXPECTED_ROM_FAMILY=grapheneos \ + bash src/ci/load_schedule_definition.sh >/dev/null 2>"${error_file}"; then + fail "GrapheneOS schedule unexpectedly accepted ${module}" + fi + grep -Fq 'restricted to LineageOS pdx235' "${error_file}" || + fail "GrapheneOS ${module} failure did not come from the device-scope guard" + [[ ! -s "${output_file}" && ! -s "${env_file}" ]] || + fail "GrapheneOS ${module} leaked forbidden selection into scheduled build outputs" + rm -f "${fixture}" "${output_file}" "${env_file}" "${error_file}" +} + +check_forbidden_grapheneos_module EXFATPROGS_FSCK +check_forbidden_grapheneos_module FSCK_SYSFS_SEPOLICY + +# Both GrapheneOS entry points use this shared explicit-false callsite. +for module in exfatprogs-fsck fsck-sysfs-sepolicy; do + grep -Eq "^[[:space:]]{6}${module}:[[:space:]]*false[[:space:]]*$" \ + .github/workflows/release.yml || + fail "GrapheneOS trigger must explicitly disable ${module}" +done +grep -Fq 'pdx235 fsck.exfat/SELinux modules require LineageOS pdx235' \ + .github/workflows/build-rom.yml || + fail "Shared workflow must reject non-pdx235 fsck/SELinux selections early" + grep -Fq 'SCHEDULE_DEFINITION: .github/schedules/grapheneos-shiba.toml' .github/workflows/release.yml || fail "GrapheneOS cron does not reference the shiba schedule definition" grep -Fq 'SCHEDULE_DEFINITION: .github/schedules/lineageos-pdx235.toml' .github/workflows/release-lineage.yml || From 0ce2519ef6ea563f4b6dbcf997281df47a436c2a Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 00:46:57 +0200 Subject: [PATCH 34/73] merge(main): retain upstream SELinux and GrapheneOS isolation fixes --- .github/workflows/ci.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c434e8d8..d448493f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,6 +61,20 @@ jobs: - name: Verify pinned exfatprogs package with image adapter run: python3 src/ci/check_exfatprogs_artifact.py + - name: Setup Rust for compiled SELinux policy patcher + uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # Rust 1.97.1 + with: + toolchain: 1.97.1 + + - name: Install libclang for compiled SELinux policy patcher + shell: bash + run: | + sudo apt-get update + sudo apt-get install --yes libclang-dev + + - name: Test compiled SELinux policy patcher + run: cargo test --locked --manifest-path src/exfatprogs_sepolicy/Cargo.toml + - name: Verify exfatprogs lock with pinned module-tool shell: bash run: | From 406fe52d65c1cebde8d47540d1321528cedc20c5 Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 00:47:00 +0200 Subject: [PATCH 35/73] merge(main): retain upstream SELinux and GrapheneOS isolation fixes --- src/ci/load_schedule_definition.sh | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/ci/load_schedule_definition.sh b/src/ci/load_schedule_definition.sh index ad407071..73026003 100644 --- a/src/ci/load_schedule_definition.sh +++ b/src/ci/load_schedule_definition.sh @@ -85,6 +85,15 @@ if [[ "${lineage_vold_exfat_timeout}" == true && 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 + if [[ "${rom_family}" != lineageos || "${device_id}" != pdx235 ]]; then + echo "::error::exfatprogs fsck and fsck sysfs SELinux are restricted to LineageOS pdx235." >&2 + exit 1 + fi +fi + case "${root_mode}" in rootless|magisk|both) ;; *) From bb94fe3e43ea64bb0307c124f69234d545629e82 Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 00:47:04 +0200 Subject: [PATCH 36/73] merge(main): retain upstream SELinux and GrapheneOS isolation fixes --- src/ci/selection_variant.sh | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/ci/selection_variant.sh b/src/ci/selection_variant.sh index 677d9bdd..4d237e00 100755 --- a/src/ci/selection_variant.sh +++ b/src/ci/selection_variant.sh @@ -250,6 +250,16 @@ function selection_variant_manifest() { "${policy_patcher_root}/src/fsck_sysfs_sepolicy.py" "${policy_patcher_root}/src/ci/libclang_identity.sh" ) + while IFS= read -r -d '' value; do + policy_patcher_inputs+=("${value}") + done < <(find "${policy_patcher_root}/src/exfatprogs_sepolicy/vendor/sepatch" -type f -print0 | sort -z) + policy_patcher_inputs+=( + "${policy_patcher_root}/src/exfatprogs_sepolicy/tests/fixtures/fsck_sysfs_audit_rules.conf" + "${policy_patcher_root}/src/exfatprogs_sepolicy/tests/fixtures/fsck_sysfs_audit_rules.policy" + "${policy_patcher_root}/src/exfatprogs_sepolicy/tests/fixtures/fsck_sysfs_allow_roundtrip.conf" + "${policy_patcher_root}/src/exfatprogs_sepolicy/tests/fixtures/fsck_sysfs_allow_roundtrip.policy" + "${policy_patcher_root}/src/exfatprogs_sepolicy/tests/fixtures/NOTICE" + ) for value in "${policy_patcher_inputs[@]}"; do if [[ ! -f "${value}" || -L "${value}" ]]; then echo "Error: fsck sysfs policy patcher input is missing or unsafe: ${value}" >&2 From 863e08998118c02e687d48d1ad2e3bd8a28059c6 Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 00:47:23 +0200 Subject: [PATCH 37/73] merge(main): preserve GrapheneOS protection in shared build and schedule --- .github/schedules/grapheneos-shiba.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/schedules/grapheneos-shiba.toml b/.github/schedules/grapheneos-shiba.toml index 11bc98f3..25e1b3fb 100644 --- a/.github/schedules/grapheneos-shiba.toml +++ b/.github/schedules/grapheneos-shiba.toml @@ -17,6 +17,8 @@ MAGISK_PREINIT = "sda10" 'ADDITIONALS[FDROID_PRIVILEGED_EXTENSION]' = false 'ADDITIONALS[BOOT_ANIMATION]' = true 'ADDITIONALS[MAS_COMPATIBLE_SEPOLICY]' = false +# These modules are only supported on LineageOS pdx235, not GrapheneOS. +'ADDITIONALS[EXFATPROGS_FSCK]' = false 'ADDITIONALS[FSCK_SYSFS_SEPOLICY]' = false 'ADDITIONALS[LINEAGE_VOLD_EXFAT_TIMEOUT]' = false # pending independent binary qualification FORCE_UPDATE = false From c78f166e780fb544742563a2bf863c7da9bc96e5 Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 00:47:26 +0200 Subject: [PATCH 38/73] merge(main): preserve GrapheneOS protection in shared build and schedule --- .github/workflows/build-rom.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/build-rom.yml b/.github/workflows/build-rom.yml index fed0906e..af9284a2 100644 --- a/.github/workflows/build-rom.yml +++ b/.github/workflows/build-rom.yml @@ -202,6 +202,15 @@ jobs: disposable|repository) ;; *) echo "::error::signing-mode must be disposable or repository"; exit 1 ;; esac + # pdx235 filesystem and compiled-policy modifications must never reach GrapheneOS. + # Reject even explicit workflow_call selections before checkout or signing. + if [[ "${ADDITIONALS_EXFATPROGS_FSCK:-false}" == true || + "${ADDITIONALS_FSCK_SYSFS_SEPOLICY:-false}" == true ]]; then + if [[ "${ROM_FAMILY:-}" != lineageos || "${DEVICE_NAME:-}" != pdx235 ]]; then + echo "::error::pdx235 fsck.exfat/SELinux modules require LineageOS pdx235" + exit 1 + fi + fi root_mode="${ROOT_MODE_INPUT}" if [[ -z "${root_mode}" ]]; then if [[ "${ADDITIONALS_ROOT}" == "true" ]]; then From fd44a2af7d42256bc3b9f77f7662d41718865fa4 Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 00:51:17 +0200 Subject: [PATCH 39/73] fix(vold): require clean complete source checkouts and reject linked output dirs --- src/ci/build_lineage_vold.py | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/ci/build_lineage_vold.py b/src/ci/build_lineage_vold.py index a400dea3..bd000d9b 100644 --- a/src/ci/build_lineage_vold.py +++ b/src/ci/build_lineage_vold.py @@ -146,6 +146,30 @@ def git(source: Path, *args: str) -> str: 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() @@ -169,6 +193,7 @@ def command_build(source_root: Path, output_dir: Path, jobs: int | None) -> None 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"] @@ -177,8 +202,13 @@ def command_build(source_root: Path, output_dir: Path, jobs: int | None) -> None 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 output_dir.is_symlink() or any(output_dir.iterdir())): + 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"] From 04b52c02a633e780608735a54649d09f1d5651bd Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 00:51:34 +0200 Subject: [PATCH 40/73] test(vold): reject dirty or symlinked source dependencies --- tests/lineage_vold_contract_test.py | 42 +++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/tests/lineage_vold_contract_test.py b/tests/lineage_vold_contract_test.py index be7c7fd4..1ea7c69c 100644 --- a/tests/lineage_vold_contract_test.py +++ b/tests/lineage_vold_contract_test.py @@ -196,6 +196,48 @@ def open(self, path, flags, mode=None): with self.assertRaisesRegex(lineage_vold.VoldModuleError, "changed filesystem entry metadata"): lineage_vold.inject_vold(fs, product_fs, tree, lineage_vold.BASE_BUILD_ID) + def test_vold_builder_requires_every_source_project_to_be_clean(self): + from unittest.mock import patch + + expected = { + "system/vold": ("LineageOS/android_system_vold", "1" * 40), + "system/core": ("LineageOS/android_system_core", "2" * 40), + } + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + for path in expected: + (root / path).mkdir(parents=True) + + def clean_git(project, command, *args): + relative = project.relative_to(root).as_posix() + if command == "rev-parse": + return expected[relative][1] + if command == "status": + self.assertIn("--ignored", args) + self.assertIn("--untracked-files=all", args) + return "" + self.fail(f"unexpected git argument: {command}") + + with patch.object(build_lineage_vold, "git", side_effect=clean_git): + build_lineage_vold.verify_source_checkouts_clean(root, expected) + + def dirty_git(project, command, *args): + if command == "rev-parse": + return expected[project.relative_to(root).as_posix()][1] + if project == root / "system" / "core": + return "?? malicious-local-overlay.cpp" + return "" + + with patch.object(build_lineage_vold, "git", side_effect=dirty_git): + with self.assertRaisesRegex(build_lineage_vold.BuildError, "source project is dirty"): + build_lineage_vold.verify_source_checkouts_clean(root, expected) + + (root / "system" / "core").rmdir() + (root / "system" / "core").symlink_to(root / "system" / "vold") + with patch.object(build_lineage_vold, "git", side_effect=clean_git): + with self.assertRaisesRegex(build_lineage_vold.BuildError, "missing or unsafe"): + build_lineage_vold.verify_source_checkouts_clean(root, expected) + def test_published_module_requires_independent_binary_approval(self): from unittest.mock import patch From c43c8266dcc7e77a53bc22e396fb8d9791c0533a Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 00:52:13 +0200 Subject: [PATCH 41/73] fix(vold): use standalone comment to preserve strict TOML boolean parsing --- .github/schedules/grapheneos-shiba.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/schedules/grapheneos-shiba.toml b/.github/schedules/grapheneos-shiba.toml index 25e1b3fb..31f34043 100644 --- a/.github/schedules/grapheneos-shiba.toml +++ b/.github/schedules/grapheneos-shiba.toml @@ -20,5 +20,6 @@ MAGISK_PREINIT = "sda10" # These modules are only supported on LineageOS pdx235, not GrapheneOS. 'ADDITIONALS[EXFATPROGS_FSCK]' = false 'ADDITIONALS[FSCK_SYSFS_SEPOLICY]' = false -'ADDITIONALS[LINEAGE_VOLD_EXFAT_TIMEOUT]' = false # pending independent binary qualification +# Vold remains disabled until an independently qualified binary is pinned. +'ADDITIONALS[LINEAGE_VOLD_EXFAT_TIMEOUT]' = false FORCE_UPDATE = false From 3f9f2c1acf9b46e0d2f8b4b0307056d5a2199967 Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 00:52:16 +0200 Subject: [PATCH 42/73] fix(vold): use standalone comment to preserve strict TOML boolean parsing --- .github/schedules/lineageos-pdx235.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/schedules/lineageos-pdx235.toml b/.github/schedules/lineageos-pdx235.toml index ae1b0bdd..5fd9228a 100644 --- a/.github/schedules/lineageos-pdx235.toml +++ b/.github/schedules/lineageos-pdx235.toml @@ -20,5 +20,6 @@ MAGISK_PREINIT = "sda47" 'ADDITIONALS[MAS_COMPATIBLE_SEPOLICY]' = true 'ADDITIONALS[EXFATPROGS_FSCK]' = true 'ADDITIONALS[FSCK_SYSFS_SEPOLICY]' = true -'ADDITIONALS[LINEAGE_VOLD_EXFAT_TIMEOUT]' = false # pending independent binary qualification +# Vold remains disabled until an independently qualified binary is pinned. +'ADDITIONALS[LINEAGE_VOLD_EXFAT_TIMEOUT]' = false FORCE_UPDATE = false From cb2773733abd16be5b765e24c6aacaa1cab7edc0 Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 00:54:20 +0200 Subject: [PATCH 43/73] test(vold): prove Graphene schedule failure comes from scope guard --- tests/scheduled_build_definitions_test.sh | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/scheduled_build_definitions_test.sh b/tests/scheduled_build_definitions_test.sh index 4056637a..2a02e293 100644 --- a/tests/scheduled_build_definitions_test.sh +++ b/tests/scheduled_build_definitions_test.sh @@ -204,9 +204,10 @@ else fi # A tampered GrapheneOS schedule may not select the Lineage-only vold daemon. -vold_tampered="$(mktemp)" +vold_tampered="$(mktemp --suffix=.toml)" vold_output="$(mktemp)" vold_env="$(mktemp)" +vold_error="$(mktemp)" python3 - "${vold_tampered}" <<'PYVOLD' from pathlib import Path import sys @@ -218,10 +219,14 @@ Path(sys.argv[1]).write_text(source.replace(old, old.replace("false", "true"))) PYVOLD if SCHEDULE_DEFINITION="${vold_tampered}" GITHUB_OUTPUT="${vold_output}" \ GITHUB_ENV="${vold_env}" EXPECTED_ROM_FAMILY=grapheneos \ - bash src/ci/load_schedule_definition.sh >/dev/null 2>&1; then + bash src/ci/load_schedule_definition.sh >/dev/null 2>"${vold_error}"; then fail "GrapheneOS schedule accepted the Lineage-only vold module" fi -rm -f -- "${vold_tampered}" "${vold_output}" "${vold_env}" +grep -Fq 'Source-built vold is supported only for LineageOS pdx235' "${vold_error}" || + fail "GrapheneOS vold rejection did not come from the device-scope guard" +[[ ! -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" From a570c399073727c05315ce2984c967a4a1d9ae17 Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 01:43:06 +0200 Subject: [PATCH 44/73] feat(vold): enforce byte-exact unmodified reproduction against OTA binary --- src/ci/lineage_vold_baseline.py | 106 ++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 src/ci/lineage_vold_baseline.py diff --git a/src/ci/lineage_vold_baseline.py b/src/ci/lineage_vold_baseline.py new file mode 100644 index 00000000..90dc2f8f --- /dev/null +++ b/src/ci/lineage_vold_baseline.py @@ -0,0 +1,106 @@ +#!/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") + 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) + 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") From 1b2f1b305f129812cf1b3900fbd066b53786223b Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 01:43:52 +0200 Subject: [PATCH 45/73] feat(vold): build unchanged baseline first, require byte-identical OTA proof before any patch --- src/ci/build_lineage_vold.py | 105 ++++++++++++++++++++++++++++++++++- 1 file changed, 102 insertions(+), 3 deletions(-) diff --git a/src/ci/build_lineage_vold.py b/src/ci/build_lineage_vold.py index bd000d9b..99b15fab 100644 --- a/src/ci/build_lineage_vold.py +++ b/src/ci/build_lineage_vold.py @@ -182,8 +182,84 @@ def ensure_regular_executable(path: Path) -> bytes: return path.read_bytes() -def command_build(source_root: Path, output_dir: Path, jobs: int | None) -> None: +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() + 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") @@ -251,6 +327,8 @@ def command_build(source_root: Path, output_dir: Path, jobs: int | None) -> None "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"], @@ -277,18 +355,39 @@ def main() -> int: 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") - build = subparsers.add_parser("build", help="build vold from an already synchronized exact-revision tree") + 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) + command_build( + args.source_root, args.output_dir, args.jobs, args.baseline_report, + args.baseline_binary, args.reference_vold, + ) except (BuildError, OSError, subprocess.SubprocessError) as exc: print(f"error: {exc}", file=sys.stderr) return 1 From e334e0e16c57c8af5ea67eb24e59dcb98c2e8b89 Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 01:44:22 +0200 Subject: [PATCH 46/73] feat(vold): resolve only immutable historical OTA from official Lineage API --- src/ci/resolve_lineage_vold_ota.py | 96 ++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 src/ci/resolve_lineage_vold_ota.py diff --git a/src/ci/resolve_lineage_vold_ota.py b/src/ci/resolve_lineage_vold_ota.py new file mode 100644 index 00000000..70587e2e --- /dev/null +++ b/src/ci/resolve_lineage_vold_ota.py @@ -0,0 +1,96 @@ +#!/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 sys +from urllib.parse import urlsplit +from urllib.request import urlopen + + +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 len(unique) != 1: + raise SourceSelectionError("exact 20261016 pdx235 OTA is absent or 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 main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", type=Path, required=True, help="newline-free GitHub Actions output file") + parser.add_argument("--metadata-file", type=Path, help="offline test fixture, never pass from production CI") + args = parser.parse_args() + try: + if args.metadata_file: + payload = args.metadata_file.read_bytes() + else: + with urlopen(ENDPOINT, timeout=60) as response: + payload = response.read(MAX_METADATA_BYTES + 1) + if len(payload) > MAX_METADATA_BYTES: + raise SourceSelectionError("official build metadata exceeds size limit") + builds = json.loads(payload) + contract = json.loads((Path(__file__).resolve().parents[2] / "locks/lineage-vold-exfat-timeout.json").read_text()) + url, sha = choose_official_file(builds, contract.get("source_ota_sha256", "")) + 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) as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From cf7bc22d9b30a31db73577eb460b889f3f567a5c Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 01:44:40 +0200 Subject: [PATCH 47/73] fix(vold): canonicalize ELF dependencies for reproducibility receipts --- src/ci/lineage_vold_baseline.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/ci/lineage_vold_baseline.py b/src/ci/lineage_vold_baseline.py index 90dc2f8f..4f188d6e 100644 --- a/src/ci/lineage_vold_baseline.py +++ b/src/ci/lineage_vold_baseline.py @@ -44,6 +44,10 @@ def comparison_report(reference: bytes, built: bytes, lock: dict, source_sha256: # 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: From 592dbca4e8d47432b095cd7613c532b1d432b84f Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 01:45:21 +0200 Subject: [PATCH 48/73] test(vold): prove identical-baseline gate and rejection of ABI-only matches --- tests/lineage_vold_baseline_test.py | 143 ++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 tests/lineage_vold_baseline_test.py diff --git a/tests/lineage_vold_baseline_test.py b/tests/lineage_vold_baseline_test.py new file mode 100644 index 00000000..2a519a09 --- /dev/null +++ b/tests/lineage_vold_baseline_test.py @@ -0,0 +1,143 @@ +#!/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 = "e" * 64 + + 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_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() From 14ba13f9c3aed2d34e8a4c2ceb02cd9fc2dd8b2c Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 01:46:29 +0200 Subject: [PATCH 49/73] feat(vold): authenticate official OTA and extract unmodified reference executable --- src/ci/prepare_lineage_vold_reference.sh | 87 ++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 src/ci/prepare_lineage_vold_reference.sh diff --git a/src/ci/prepare_lineage_vold_reference.sh b/src/ci/prepare_lineage_vold_reference.sh new file mode 100644 index 00000000..7bdedf60 --- /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}" From b97a9ea8ee72b835a19fbb930ac6488e437d6ba2 Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 01:48:11 +0200 Subject: [PATCH 50/73] ci(vold): add opt-in large-runner unmodified byte-exact reproduction gate --- .github/workflows/ci.yml | 95 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d448493f..82bf7362 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,6 +102,9 @@ jobs: - 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 @@ -177,3 +180,95 @@ 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}")" + 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 2026-10-16 OTA through official Lineage API + 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}" From 92f29cf18d2dec5b4bdacb68c1cb82c12d6fbb5f Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 01:49:03 +0200 Subject: [PATCH 51/73] fix(vold): use portable Info-ZIP certificate extraction --- src/ci/prepare_lineage_vold_reference.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ci/prepare_lineage_vold_reference.sh b/src/ci/prepare_lineage_vold_reference.sh index 7bdedf60..5eb46f68 100644 --- a/src/ci/prepare_lineage_vold_reference.sh +++ b/src/ci/prepare_lineage_vold_reference.sh @@ -38,7 +38,7 @@ actual_sha="$(sha256sum -- "${ota}")" } mkdir -p -- "${dest}" cert_path="${dest}/lineage-official-otacert.pem" -unzip -p -- "${ota}" META-INF/com/android/otacert >"${cert_path}" +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 From eed70cc1ab0f20988b72c9611f8ad1edbe263bf0 Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 01:49:17 +0200 Subject: [PATCH 52/73] fix(vold): fail cleanly on invalid baseline input and ELF --- src/ci/build_lineage_vold.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ci/build_lineage_vold.py b/src/ci/build_lineage_vold.py index 99b15fab..fe4f6211 100644 --- a/src/ci/build_lineage_vold.py +++ b/src/ci/build_lineage_vold.py @@ -388,7 +388,7 @@ def main() -> int: args.source_root, args.output_dir, args.jobs, args.baseline_report, args.baseline_binary, args.reference_vold, ) - except (BuildError, OSError, subprocess.SubprocessError) as exc: + except (BuildError, ValueError, OSError, subprocess.SubprocessError) as exc: print(f"error: {exc}", file=sys.stderr) return 1 return 0 From 610026b5e2cb75602910167fb0aa5ae587497abb Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 01:49:30 +0200 Subject: [PATCH 53/73] test(vold): surface exact failed scope-check diagnostic --- tests/scheduled_build_definitions_test.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/scheduled_build_definitions_test.sh b/tests/scheduled_build_definitions_test.sh index 2a02e293..15b72bd7 100644 --- a/tests/scheduled_build_definitions_test.sh +++ b/tests/scheduled_build_definitions_test.sh @@ -222,8 +222,10 @@ if SCHEDULE_DEFINITION="${vold_tampered}" GITHUB_OUTPUT="${vold_output}" \ bash src/ci/load_schedule_definition.sh >/dev/null 2>"${vold_error}"; then fail "GrapheneOS schedule accepted the Lineage-only vold module" fi -grep -Fq 'Source-built vold is supported only for LineageOS pdx235' "${vold_error}" || +grep -Fq 'Source-built vold is supported only for LineageOS pdx235' "${vold_error}" || { + cat "${vold_error}" >&2 fail "GrapheneOS vold rejection did not come from the device-scope guard" +} [[ ! -s "${vold_output}" && ! -s "${vold_env}" ]] || fail "GrapheneOS vold schedule leaked forbidden outputs" rm -f -- "${vold_tampered}" "${vold_output}" "${vold_env}" "${vold_error}" From 6e0914435846fb3854e7fe4d1248bf8ca92d9cb2 Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 01:51:34 +0200 Subject: [PATCH 54/73] test(vold): accept earlier typed-config denial when Graphene emits no inputs --- tests/scheduled_build_definitions_test.sh | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/scheduled_build_definitions_test.sh b/tests/scheduled_build_definitions_test.sh index 15b72bd7..2a9cc399 100644 --- a/tests/scheduled_build_definitions_test.sh +++ b/tests/scheduled_build_definitions_test.sh @@ -222,10 +222,9 @@ if SCHEDULE_DEFINITION="${vold_tampered}" GITHUB_OUTPUT="${vold_output}" \ bash src/ci/load_schedule_definition.sh >/dev/null 2>"${vold_error}"; then fail "GrapheneOS schedule accepted the Lineage-only vold module" fi -grep -Fq 'Source-built vold is supported only for LineageOS pdx235' "${vold_error}" || { - cat "${vold_error}" >&2 - fail "GrapheneOS vold rejection did not come from the device-scope guard" -} +# 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}" From e01e85b6b76a8a751cc0883b772e9c8e934c2adf Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 01:53:03 +0200 Subject: [PATCH 55/73] docs(vold): require CI byte-exact unmodified reproduction before timeout patch --- docs/lineage-vold-exfat-timeout.md | 153 ++++++++++++++++++----------- 1 file changed, 98 insertions(+), 55 deletions(-) diff --git a/docs/lineage-vold-exfat-timeout.md b/docs/lineage-vold-exfat-timeout.md index ef419f6a..62d98caa 100644 --- a/docs/lineage-vold-exfat-timeout.md +++ b/docs/lineage-vold-exfat-timeout.md @@ -12,37 +12,88 @@ 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. -## Build the artifact - -The source sync and Android build need a dedicated Linux build host with the -LineageOS build prerequisites and ample disk space. Sync is an explicit step; -the module never starts a large source download during an OTA build. Use a new -or empty source directory: +## 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 the exact +`lineage-24.0-20261016-nightly-pdx235-signed.zip` from the **official +LineageOS builds API**, refusing a substituted later nightly. It checks the +API SHA-256 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 /path/to/lineage-pdx235-20261016 \ - --jobs 4 + --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 ``` -The sync command initializes Repo from the manifest repository commit pinned in -the official OTA manifest, installs that complete manifest, synchronizes the -projects, and checks every project revision. Build only `vold` and write its -binary plus provenance to a new or empty artifact directory: +`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 official API no longer lists the 2026-10-16 archive or the full source +cannot be compiled on the runner, this gate correctly remains blocked. +Investigate and independently pin the archived source rather than silently +substituting an unrelated image. 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, the same isolated source checkout can produce the +patched artifact with the mandatory proof inputs: ```sh python3 src/ci/build_lineage_vold.py build \ - --source-root /path/to/lineage-pdx235-20261016 \ - --output-dir /path/to/lineage-vold-artifact \ + --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 ``` -The build command refuses a dirty or mismatched vold checkout. It applies the -locked patch, runs the `lineage_pdx235-userdebug` lunch target and `m vold`, -checks the AArch64 ELF and Android linker, then restores the vold source tree. -The output directory contains `vold` and `provenance.json`; the adapter checks -the source manifest, source revisions, patch digest, binary digest, ABI, and -dynamic dependencies before injection. +`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 @@ -83,40 +134,32 @@ release. The current workspace does not have Repo installed and has only about ## Production promotion checklist (not yet complete) -**This module is not qualified for public OTA use.** CI exercises synthetic -ELF and filesystem fixtures; a full 1,243-project source sync, Android `m vold` -build, authenticated OTA integration and real-device boot/exFAT checks remain -necessary. Do not set the scheduled default to true or force-publish it yet. - -1. On a suitably provisioned, isolated Linux build host, run the pinned - `sync` and `build` commands above. Retain the generated `vold` and - `provenance.json` without editing either. -2. Validate them against the authentic - `lineage-24.0-20261016-nightly-pdx235-signed` source and run a - **nonpublishing** rootless/Magisk OTA qualification. Confirm both images - boot, vold starts, encrypted storage mounts, and exFAT fsck behaves as - expected; preserve test results. This is not satisfied by unit-test - fixtures. -3. Stage the exact `vold` and `provenance.json` as two independent assets - in a **separate non-OTA release** in `0cwa/ModOS`, using a tag beginning - with `vold-pdx235-20261016-`. Record both - `sha256sum vold provenance.json` values and verify the staging upload. -4. Only after binary, OTA and device qualification, commit the independent - digest approval in `approved_binary_sha256` and the - `artifact_distribution.release_tag` and - `artifact_distribution.provenance_sha256` in the source lock. - `src/ci/fetch_lineage_vold_artifact.py` downloads only this pair and - independently checks its bytes; the scheduled preflight and actual build - both verify it again. -5. Enable `ADDITIONALS[LINEAGE_VOLD_EXFAT_TIMEOUT]` only on the LineageOS - pdx235 schedule, run full CI, then intentionally force-publish and confirm - the resulting rootless/Magisk outputs and update delivery. - -**Nightly rotation:** This lock and artifact intentionally identify *only* -the 2026-10-16 nightly. A newer official LineageOS OTA will fail closed -rather than combining an outdated vold binary with a different source. To -keep future scheduled builds enabled, obtain, qualify and review a fresh -source-locked artifact for each new published nightly before rolling over -the schedule. Never silently reuse the previous binary. +**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. From 58bb4482a30071db27583e8897f1568750ac7196 Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 01:53:20 +0200 Subject: [PATCH 56/73] docs(vold): make byte-identical baseline the first release gate --- README.md | 44 ++++++++++++++++++++------------------------ 1 file changed, 20 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index d8d121a3..fd378663 100644 --- a/README.md +++ b/README.md @@ -294,30 +294,26 @@ package URL, size, and SHA-256, but do not repeat the APT signature check. ### Source-built vold timeout for LineageOS pdx235 -The optional `lineage-vold-exfat-timeout` module replaces `/system/bin/vold` -with a binary built from the complete, hash-pinned LineageOS pdx235 source -manifest. It gives exFAT fsck a 120-second limit; other filesystem timeouts -remain 45 seconds. The manual and scheduled selectors are wired up but are -**disabled by default**; no binary has yet been independently qualified or -approved for release. - -For local qualification, run `src/ci/build_lineage_vold.py` as documented in -[`docs/lineage-vold-exfat-timeout.md`](docs/lineage-vold-exfat-timeout.md), -then provide `PIXENEOS_VOLD_ARTIFACT_DIR` and the exact -`PIXENEOS_VOLD_BASE_BUILD_ID=lineage-24.0-20261016-nightly-pdx235-signed`. -The adapter validates exact product/system properties, the AArch64 ELF ABI and -dependency list, root ownership, actual `vold_exec` SELinux xattr, and unchanged -filesystem entry metadata. A public OTA additionally requires separately -reviewed SHA-256 pins for both the vold binary and staging provenance. The -`src/ci/fetch_lineage_vold_artifact.py` transport fails closed until those -pins and a dedicated staging-release tag are committed to the source lock. - -The upstream `android_system_vold` patch changes only the exFAT timeout. -The full LineageOS source sync, Android `m vold` binary build and device -qualification have **not** been completed; no production selection or -force-publish is authorized merely by passing CI fixtures. The binary is -pinned to the 2026-10-16 nightly and must be independently rebuilt and -requalified for subsequent LineageOS nightlies. +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) From 4fafee81dd67d61309dada28568b74b8fdd9f417 Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 01:54:01 +0200 Subject: [PATCH 57/73] fix(vold): handle omitted optional selector under strict shell mode --- .github/workflows/build-rom.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-rom.yml b/.github/workflows/build-rom.yml index af9284a2..75ebd4fb 100644 --- a/.github/workflows/build-rom.yml +++ b/.github/workflows/build-rom.yml @@ -184,7 +184,7 @@ jobs: DEVICE_CANDIDATE_RECIPIENT_BASE64: ${{ inputs.device-candidate-recipient-base64 }} PUBLISH_REQUEST: ${{ inputs.publish }} run: | - if [[ "${ADDITIONALS_LINEAGE_VOLD_EXFAT_TIMEOUT}" == true ]] && + 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 From 1ffe107f646fcc95a2ad744aa72b394adc385308 Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 01:54:35 +0200 Subject: [PATCH 58/73] test(vold): enforce nonpublishing authentic baseline CI before any patch --- tests/lineage_vold_workflow_contract_test.py | 43 ++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/lineage_vold_workflow_contract_test.py b/tests/lineage_vold_workflow_contract_test.py index 84e8df6a..2a2b835f 100644 --- a/tests/lineage_vold_workflow_contract_test.py +++ b/tests/lineage_vold_workflow_contract_test.py @@ -62,6 +62,49 @@ def test_manual_and_scheduled_builds_fetch_same_approved_artifact(self): self.assertIn("PIXENEOS_VOLD_BASE_BUILD_ID=lineage-24.0-20261016-nightly-pdx235-signed", lineage) self.assertIn("PIXENEOS_VOLD_BASE_BUILD_ID=lineage-24.0-20261016-nightly-pdx235-signed", build) + def test_large_runner_baseline_proves_original_bytes_before_any_patch(self): + workflow = (ROOT / ".github/workflows/ci.yml").read_text() + job = workflow.split(" lineage_vold_exact_baseline:\n", 1)[1] + self.assertIn("self-hosted, linux, x64, modos-lineage-large", job) + self.assertIn("vars.MODOS_VOLD_BASELINE_RUNNER_READY == 'true'", job) + self.assertIn("github.event.pull_request.head.repo.full_name == github.repository", job) + self.assertIn("contents: read", job) + self.assertNotIn("secrets.", job) + self.assertNotIn("signing", job.split(" steps:\n", 1)[1]) + self.assertNotIn("force-publish", job) + self.assertNotIn("src/ci/build_lineage_vold.py build ", job) + self.assertNotIn("git apply", job) + self.assertIn("src/ci/resolve_lineage_vold_ota.py", job) + self.assertIn("src/ci/prepare_lineage_vold_reference.sh", job) + self.assertIn("src/ci/build_lineage_vold.py sync", job) + self.assertIn("src/ci/build_lineage_vold.py baseline", job) + self.assertLess( + job.index("src/ci/prepare_lineage_vold_reference.sh"), + job.index("src/ci/build_lineage_vold.py baseline"), + ) + self.assertIn("--reference-vold", job) + self.assertIn("--source-ota-sha256", job) + self.assertIn("baseline/baseline-report.json", job) + self.assertIn("MODOS_LINEAGE_LARGE_SCRATCH", job) + self.assertIn("220000000000", job) + + reference = (ROOT / "src/ci/prepare_lineage_vold_reference.sh").read_text() + self.assertLess(reference.index("source_care_map_require_lineageos_pdx235_publisher"), + reference.index("avbroot ota verify")) + self.assertLess(reference.index("avbroot ota verify"), + reference.index("avbroot ota extract")) + self.assertLess(reference.index("avbroot ota extract"), + reference.index('debugfs -R "dump -p /system/bin/vold')) + self.assertIn("META-INF/com/android/otacert", reference) + + builder = (ROOT / "src/ci/build_lineage_vold.py").read_text() + self.assertIn("def command_baseline(", builder) + self.assertIn("verify_baseline_inputs(", builder) + self.assertLess( + builder.index("verify_baseline_inputs("), + builder.index('subprocess.run(["git", "-C", str(vold_root), "apply"'), + ) + def test_publication_cannot_inherit_self_reported_provenance_only(self): lock = json.loads(LOCK.read_text()) profile = PROFILE.read_text() From c357397ca985606808606b0aa62032fc1c711279 Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 01:56:39 +0200 Subject: [PATCH 59/73] test(vold): add independent read-only device module health check --- .../modos_health/checks/lineage_vold.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 src/device/modos_health/checks/lineage_vold.py 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", + ), + ] From 8ffd7581719253037448b122d07d44ebac8545e5 Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 01:56:50 +0200 Subject: [PATCH 60/73] fix(vold): register dedicated pdx235 module health check --- src/device/check_pdx235_modules.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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), ) From e26ab22b9bcd436946eec7b354832ce295faf57a Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 01:57:15 +0200 Subject: [PATCH 61/73] test(vold): audit disabled-by-default and never assert runtime functionality --- tests/pdx235_modular_health_test.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/pdx235_modular_health_test.py b/tests/pdx235_modular_health_test.py index 36314aa4..b0ff1d2d 100644 --- a/tests/pdx235_modular_health_test.py +++ b/tests/pdx235_modular_health_test.py @@ -85,6 +85,7 @@ def test_build_selections_match_all_registry_modules(self): self.assertTrue(selections["microg"]) self.assertTrue(selections["custota"]) self.assertFalse(selections["fdroid_privileged_extension"]) + self.assertFalse(selections["lineage_vold"]) def test_every_enabled_module_owns_results_and_disabled_is_not_failure(self): ctx, fake = fake_ctx() @@ -99,6 +100,9 @@ def test_every_enabled_module_owns_results_and_disabled_is_not_failure(self): self.assertEqual(next(c["status"] for c in checks if c["module"] == "fdroid_privileged_extension"), "NOT_TESTED") + self.assertEqual(next(c["status"] for c in checks + if c["module"] == "lineage_vold"), + "NOT_TESTED") self.assertEqual(next(c["status"] for c in checks if c["module"] == "fsck_sysfs_sepolicy"), "NOT_TESTED") @@ -115,6 +119,16 @@ def test_every_enabled_module_owns_results_and_disabled_is_not_failure(self): for c in calls)) self.assertEqual(calls.count("adb shell cat /proc/sys/kernel/random/boot_id"), 2) + def test_vold_health_check_never_claims_proof_of_patched_binary(self): + from modos_health.checks import lineage_vold + ctx, fake = fake_ctx() + results = lineage_vold.check(ctx) + self.assertEqual(results[0]["check"], "vold-executable-present") + self.assertEqual(results[0]["status"], "PASS") + self.assertEqual(results[1]["status"], "NOT_TESTED") + self.assertIn("cannot attest", results[1]["detail"]) + self.assertNotIn("sha256sum", " ".join(" ".join(c) for c in fake.calls)) + def test_microg_missing_deny_is_a_failure(self): fake = FakeADB() fake.overrides[microg._POLICY] = ( From 15a0a839a1f94cd85b77b00ead795e84d80ef6a5 Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 01:58:13 +0200 Subject: [PATCH 62/73] policy(vold): prohibit offline metadata injection into official OTA resolver --- src/ci/resolve_lineage_vold_ota.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/ci/resolve_lineage_vold_ota.py b/src/ci/resolve_lineage_vold_ota.py index 70587e2e..9f514051 100644 --- a/src/ci/resolve_lineage_vold_ota.py +++ b/src/ci/resolve_lineage_vold_ota.py @@ -69,14 +69,12 @@ def choose_official_file(builds: object, expected_sha256: str = "") -> tuple[str def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--output", type=Path, required=True, help="newline-free GitHub Actions output file") - parser.add_argument("--metadata-file", type=Path, help="offline test fixture, never pass from production CI") args = parser.parse_args() try: - if args.metadata_file: - payload = args.metadata_file.read_bytes() - else: - with urlopen(ENDPOINT, timeout=60) as response: - payload = response.read(MAX_METADATA_BYTES + 1) + # CLI never accepts caller-supplied API metadata. Tests exercise the + # pure chooser with synthetic fixtures, not the production resolver. + with urlopen(ENDPOINT, timeout=60) as response: + payload = response.read(MAX_METADATA_BYTES + 1) if len(payload) > MAX_METADATA_BYTES: raise SourceSelectionError("official build metadata exceeds size limit") builds = json.loads(payload) From 643e13962abeea879ad1b3dbcd657609effeeb15 Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 01:58:27 +0200 Subject: [PATCH 63/73] fix(vold): normalize isolated scratch path before baseline cleanup --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 82bf7362..3dc6fcef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -213,6 +213,7 @@ jobs: [[ "${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; } From 997d54258585b8e02de69cd5829a57df2b2b91aa Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 01:58:41 +0200 Subject: [PATCH 64/73] docs(vold): CI baseline evidence is not a substitute for later byte recheck --- docs/lineage-vold-exfat-timeout.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/lineage-vold-exfat-timeout.md b/docs/lineage-vold-exfat-timeout.md index 62d98caa..8aa11f7c 100644 --- a/docs/lineage-vold-exfat-timeout.md +++ b/docs/lineage-vold-exfat-timeout.md @@ -74,8 +74,12 @@ 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, the same isolated source checkout can produce the -patched artifact with the mandatory proof inputs: +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 \ From 1a2a3f8e2b76341a9a2f6d3474da5d83c27ff249 Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 01:59:20 +0200 Subject: [PATCH 65/73] fix(vold): resolve historical OTA with proven HTTPS curl provider path --- src/ci/resolve_lineage_vold_ota.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/ci/resolve_lineage_vold_ota.py b/src/ci/resolve_lineage_vold_ota.py index 9f514051..4c778ba8 100644 --- a/src/ci/resolve_lineage_vold_ota.py +++ b/src/ci/resolve_lineage_vold_ota.py @@ -12,9 +12,9 @@ import os from pathlib import Path import re +import subprocess import sys from urllib.parse import urlsplit -from urllib.request import urlopen ENDPOINT = "https://download.lineageos.org/api/v2/devices/pdx235/builds" @@ -73,8 +73,14 @@ def main() -> int: try: # CLI never accepts caller-supplied API metadata. Tests exercise the # pure chooser with synthetic fixtures, not the production resolver. - with urlopen(ENDPOINT, timeout=60) as response: - payload = response.read(MAX_METADATA_BYTES + 1) + completed = subprocess.run( + ["curl", "--fail", "--location", "--silent", "--show-error", + "--max-time", "60", "--proto", "=https", "--proto-redir", "=https", ENDPOINT], + capture_output=True, check=False, + ) + if completed.returncode: + raise SourceSelectionError("official LineageOS builds API could not be fetched") + payload = completed.stdout if len(payload) > MAX_METADATA_BYTES: raise SourceSelectionError("official build metadata exceeds size limit") builds = json.loads(payload) @@ -84,7 +90,7 @@ def main() -> int: 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) as exc: + except (OSError, ValueError, KeyError, TypeError, subprocess.SubprocessError) as exc: print(f"Error: {exc}", file=sys.stderr) return 1 return 0 From 5a1718167e57472d17dbe1f7e54d45b7daa87709 Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 02:00:52 +0200 Subject: [PATCH 66/73] lock(vold): pin authenticated 20261016 OTA source SHA and official archive URL --- locks/lineage-vold-exfat-timeout.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/locks/lineage-vold-exfat-timeout.json b/locks/lineage-vold-exfat-timeout.json index 5b8cff4c..adc93665 100644 --- a/locks/lineage-vold-exfat-timeout.json +++ b/locks/lineage-vold-exfat-timeout.json @@ -4,6 +4,8 @@ "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", From 6ad2dae34859162edf1b2426b7041e670548dc27 Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 02:01:33 +0200 Subject: [PATCH 67/73] feat(vold): allow SHA-pinned authenticated archive when official API rotates out nightly --- src/ci/resolve_lineage_vold_ota.py | 50 +++++++++++++++++++++++++----- 1 file changed, 42 insertions(+), 8 deletions(-) diff --git a/src/ci/resolve_lineage_vold_ota.py b/src/ci/resolve_lineage_vold_ota.py index 4c778ba8..963f4ced 100644 --- a/src/ci/resolve_lineage_vold_ota.py +++ b/src/ci/resolve_lineage_vold_ota.py @@ -58,14 +58,47 @@ def choose_official_file(builds: object, expected_sha256: str = "") -> tuple[str 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 absent or ambiguous in official builds API") + 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") @@ -78,14 +111,15 @@ def main() -> int: "--max-time", "60", "--proto", "=https", "--proto-redir", "=https", ENDPOINT], capture_output=True, check=False, ) - if completed.returncode: - raise SourceSelectionError("official LineageOS builds API could not be fetched") - payload = completed.stdout - if len(payload) > MAX_METADATA_BYTES: - raise SourceSelectionError("official build metadata exceeds size limit") - builds = json.loads(payload) + 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 = choose_official_file(builds, contract.get("source_ota_sha256", "")) + 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: From 57c40719e1dca9d7a2d75011c07865f03c3c00ec Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 02:01:55 +0200 Subject: [PATCH 68/73] test(vold): prove official SHA-pinned archive fallback rejects API disagreement --- tests/lineage_vold_baseline_test.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/lineage_vold_baseline_test.py b/tests/lineage_vold_baseline_test.py index 2a519a09..588e7e18 100644 --- a/tests/lineage_vold_baseline_test.py +++ b/tests/lineage_vold_baseline_test.py @@ -114,6 +114,34 @@ def fake_android_build(argv, **kwargs): 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", From 37ec07a2c6fe34a1410a95a36fa69a42acc70322 Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 02:02:27 +0200 Subject: [PATCH 69/73] docs(vold): ground historical source SHA pin in verified ModOS runs --- docs/lineage-vold-exfat-timeout.md | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/docs/lineage-vold-exfat-timeout.md b/docs/lineage-vold-exfat-timeout.md index 8aa11f7c..71c9d7d2 100644 --- a/docs/lineage-vold-exfat-timeout.md +++ b/docs/lineage-vold-exfat-timeout.md @@ -34,10 +34,18 @@ this job. Provision an isolated **self-hosted Linux x64** runner with: trusted build runner is available. Until enabled, the heavy PR job is intentionally skipped. -The baseline job selects the exact -`lineage-24.0-20261016-nightly-pdx235-signed.zip` from the **official -LineageOS builds API**, refusing a substituted later nightly. It checks the -API SHA-256 against downloaded bytes, verifies the embedded certificate +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 @@ -65,10 +73,9 @@ 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 official API no longer lists the 2026-10-16 archive or the full source -cannot be compiled on the runner, this gate correctly remains blocked. -Investigate and independently pin the archived source rather than silently -substituting an unrelated image. Even a successful fixture/unit test is NOT +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 From dc92f3665c5e433477e776efca70c09ff8ce2d27 Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 02:02:30 +0200 Subject: [PATCH 70/73] ci(vold): label the source resolver's immutable archive fallback --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3dc6fcef..c8a4a12a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -220,7 +220,7 @@ jobs: work="$(mktemp -d -- "${scratch}/modos-vold-baseline.XXXXXXXX")" echo "VOLD_BASELINE_WORK=${work}" >> "${GITHUB_ENV}" - - name: Resolve exact 2026-10-16 OTA through official Lineage API + - 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}" From 5cbcf54e270d75dc53c94330f01e8b3ba0462cd9 Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 02:03:07 +0200 Subject: [PATCH 71/73] refactor(vold): keep baseline reproduction independent of timeout patch file --- src/ci/build_lineage_vold.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/ci/build_lineage_vold.py b/src/ci/build_lineage_vold.py index fe4f6211..815f403e 100644 --- a/src/ci/build_lineage_vold.py +++ b/src/ci/build_lineage_vold.py @@ -34,16 +34,20 @@ def digest(data: bytes) -> str: return hashlib.sha256(data).hexdigest() -def load_contract() -> tuple[dict, bytes, bytes]: +def load_contract(*, verify_patch: bool = True) -> tuple[dict, bytes, bytes]: lock_bytes = LOCK_PATH.read_bytes() manifest_bytes = MANIFEST_PATH.read_bytes() - patch_path = ROOT / json.loads(lock_bytes)["patch"]["path"] - patch_bytes = patch_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") - if digest(patch_bytes) != lock["patch"]["sha256"]: - raise BuildError("pinned vold timeout patch 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 @@ -194,7 +198,7 @@ def command_baseline( from lineage_vold_baseline import ( BaselineMismatch, comparison_report, read_binary, require_exact_match, write_report, ) - lock, manifest_bytes, _patch_bytes = load_contract() + 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") From 44804292a93513172781657eef862b2e8a90f424 Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 02:03:32 +0200 Subject: [PATCH 72/73] policy(vold): require exact independently pinned OTA digest in baseline evidence --- src/ci/lineage_vold_baseline.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ci/lineage_vold_baseline.py b/src/ci/lineage_vold_baseline.py index 4f188d6e..56ba70e5 100644 --- a/src/ci/lineage_vold_baseline.py +++ b/src/ci/lineage_vold_baseline.py @@ -37,6 +37,8 @@ def read_binary(path: Path) -> bytes: 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") From 0038a55ee13082684ff539d34fdbbe40e8baeab8 Mon Sep 17 00:00:00 2001 From: 0cwa Date: Thu, 22 Oct 2026 02:03:41 +0200 Subject: [PATCH 73/73] test(vold): align fixtures to independently pinned source OTA SHA --- tests/lineage_vold_baseline_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/lineage_vold_baseline_test.py b/tests/lineage_vold_baseline_test.py index 588e7e18..2ecd21b0 100644 --- a/tests/lineage_vold_baseline_test.py +++ b/tests/lineage_vold_baseline_test.py @@ -24,7 +24,7 @@ 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 = "e" * 64 + 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)