Skip to content
Merged
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,20 @@ waits for current Forge work to become idle, and updates with rollback. It
preserves the Docker disk, Forge state, WebUI password, provider keys, and CVE
data. Do not run `prepare-firmware.sh`, `--wipe`, or `--disk-gib` for an update.

When the payload module version differs from the installed one, the update
parks Forge, reinstalls the module (fetching and hash-verifying the pinned
Docker Engine archive when neither the bundle nor the phone holds it), and
reboots. Every update and installation resume then proves the installed
payload, with the overlay confined to the KernelSU module tree while the
Docker daemon runs from the separately managed /data/docker release tree:
the payload files are re-staged through a
mode-preserving overlay, verified on the phone against a payload-built
checksum manifest, and pruned of files the payload no longer contains. The
verification exists because KernelSU's module update can preserve previously
installed file bytes - observed as a fresh module.prop beside a stale
bin/hostctl on a Pixel 11 Pro XL on 2026-09-15 - and a silent mismatch would
leave boot-time module code running older behavior than the release claims.

## What gets installed

- The matched Pixel kernel and native Docker host
Expand Down
1 change: 1 addition & 0 deletions deployment/build-simple-package.sh
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ IFS='|' read -r LOCK_PIXEL_REVISION LOCK_FORGE_REVISION LOCK_FORGE_SOURCE_SHA256
mkdir -p "$OUTPUT/payload" "$WORK/ops"
cp "$SCRIPT_DIR/simple-install.sh" "$OUTPUT/install.sh"
cp "$SCRIPT_DIR/prepare-firmware.sh" "$OUTPUT/prepare-firmware.sh"
cp "$PROJECT_ROOT/tools/engine.json" "$OUTPUT/engine.json"
cp "$SCRIPT_DIR/SIMPLE-INSTALLER.md" "$OUTPUT/README.md"
cp "$MODULE" "$OUTPUT/payload/host-module.zip"
if [[ -n "$ENGINE" ]]; then
Expand Down
95 changes: 93 additions & 2 deletions deployment/simple-install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,86 @@ push() {
"$ADB_BIN" -s "$SERIAL" push "$1" "$2" >/dev/null
}

# The pinned Docker Engine identity is read only from the engine.tarball
# object; the binaries block carries its own size and sha256 keys and must
# never satisfy these parses.
engine_tarball_block() {
sed -n '/"tarball": {/,/}/p' "$1"
}

engine_archive_name() {
local engine_json=$SCRIPT_DIR/engine.json url
[[ -f "$engine_json" ]] || engine_json=$SCRIPT_DIR/../tools/engine.json
url=$(engine_tarball_block "$engine_json" 2>/dev/null | sed -n 's/.*"url": "\([^"]*\)".*/\1/p')
printf '%s' "${url##*/}"
[[ -n "$url" ]]
}
Comment on lines +183 to +189

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new die guard here can't actually stop the installer — it only kills a subshell.

engine_archive_name() is only ever invoked via command substitution:

push "$PAYLOAD/docker-engine.tgz" "/data/local/tmp/$(engine_archive_name)"   # line 524
phone "rm -f /data/local/tmp/$(engine_archive_name) /data/local/tmp/Image-CP2A.260805.005.lz4 ..."  # line 540

$(...) always forks a subshell to capture stdout. When name is empty (missing/malformed engine.json, or the fallback $SCRIPT_DIR/../tools/engine.json also absent), die 'cannot read the pinned Docker Engine identity' calls exit 1 — but that only terminates the subshell. The parent script never sees the failure: the exit status of a command substitution embedded mid-word is discarded (it's not a bare assignment, so set -e doesn't fire on it either).

The visible effect: the diagnostic prints to stderr, but the script keeps going with an empty substitution, so:

  • line 524 becomes push "$PAYLOAD/docker-engine.tgz" "/data/local/tmp/" — adb pushes into that directory under the source basename (docker-engine.tgz), not the pinned name kernelctl/prepare-engine on the module side expect.
  • line 540 becomes rm -f /data/local/tmp/ ... — a silent no-op on the directory (-f swallows the "is a directory" error).

So the exact safety check this PR adds ("guard the pin name") is dead code in the one case it exists to catch, and a broken bundle fails later with a confusing device-side error instead of the clean die message here.

Contrast with hash_file/verify_file elsewhere in this same script, which are always called as plain statements (never inside $(...)) specifically so their sets/exits reach the caller — engine_archive_name breaks that existing convention.

Suggested fix: capture into a variable and check explicitly at each call site, e.g. name=$(engine_archive_name) || die '...'; push ... "/data/local/tmp/$name", rather than interpolating the call directly into a larger string.

Comment on lines +183 to +189

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

engine_archive_name() can never fail, so its callers' || die 'cannot read the pinned Docker Engine identity' guards (lines 522 and 539) are dead code.

If engine.json is unreadable — both $SCRIPT_DIR/engine.json and $SCRIPT_DIR/../tools/engine.json missing/malformed — url parses to empty, and the function's last statement, printf '%s' "${url##*/}", prints an empty string but still exits 0. The $(...) substitution at each call site therefore "succeeds" with an empty value:

  • Lines 522-523: push "$PAYLOAD/docker-engine.tgz" "/data/local/tmp/$bundled_engine_name" silently becomes push ... /data/local/tmp/. adb push to an existing directory preserves the source basename, so the file lands as /data/local/tmp/docker-engine.tgz instead of the pinned identity's filename (docker-<version>.tgz) that module/bin/prepare-engine's sideload lookup ($SIDELOAD_ROOT/$TARBALL_NAME) expects.
  • Lines 539-540: phone "rm -f /data/local/tmp/ /data/local/tmp/Image-....lz4 ..." attempts rm -f on a bare directory path. Under set -euo pipefail, a non-zero exit from that phone call aborts the installer right after the post-install reboot, with a failure message that never surfaces the real cause (unreadable engine.json).

Compare with ensure_engine_archive() a few lines below, which correctly guards the identical parse with [[ -n "$engine_url" && -n "$engine_size" && -n "$engine_sha" ]] || die .... engine_archive_name() needs the same validation (or should exit non-zero when url is empty) so the || die at each call site actually fires.

Reachability today is narrow — build-simple-package.sh now always copies tools/engine.json into the package next to install.sh, so a normally-built release won't hit this — but it's a real gap in the intended defensive check, and the failure mode if it ever does trigger (silent wrong filename, or an opaque set -e abort) is worse than the clear die the code was clearly meant to produce.


ensure_engine_archive() {
local engine_json engine_url engine_name engine_size engine_sha archive held held_size held_sha
engine_json=$SCRIPT_DIR/engine.json
[[ -f "$engine_json" ]] || engine_json=$SCRIPT_DIR/../tools/engine.json
engine_url=
engine_name=
engine_size=
engine_sha=
if [[ -f "$engine_json" ]]; then
engine_url=$(engine_tarball_block "$engine_json" | sed -n 's/.*"url": "\([^"]*\)".*/\1/p')
engine_name=${engine_url##*/}
engine_size=$(engine_tarball_block "$engine_json" | sed -n 's/.*"size": \([0-9][0-9]*\).*/\1/p')
engine_sha=$(engine_tarball_block "$engine_json" | sed -n 's/.*"sha256": "\([0-9a-f]\{64\}\)".*/\1/p')
fi
[[ -n "$engine_url" && -n "$engine_size" && -n "$engine_sha" ]] \
|| die 'cannot read the pinned Docker Engine identity'
if phone "test -s /data/local/tmp/$engine_name" >/dev/null 2>&1; then
held=$(phone "wc -c < /data/local/tmp/$engine_name; sha256sum /data/local/tmp/$engine_name" 2>/dev/null | tr -d '\r') || held=
held_size=$(printf '%s\n' "$held" | sed -n '1s/^[[:space:]]*\([0-9][0-9]*\).*/\1/p')
held_sha=$(printf '%s\n' "$held" | sed -n '2s/^\([0-9a-f]\{64\}\) .*/\1/p')
if [[ "$held_size" == "$engine_size" && "$held_sha" == "$engine_sha" ]]; then
return 0
fi
printf 'install: the phone-held %s fails the pinned identity; refetching\n' "$engine_name" >&2
fi
archive=$(mktemp "${TMPDIR:-/tmp}/eip-engine.XXXXXX")
curl --fail --location --proto '=https' --proto-redir '=https' \
--output "$archive" "$engine_url" \
|| die 'the pinned Docker Engine archive could not be downloaded'
[[ $(wc -c < "$archive" | tr -d ' ') == "$engine_size" ]] \
|| die 'the pinned Docker Engine archive has the wrong size'
verify_file "$archive" "$engine_sha" 'Docker Engine archive'
push "$archive" "/data/local/tmp/$engine_name"
rm -f "$archive"
}

# KernelSU's module update can preserve previously installed file bytes
# (observed on the Pixel 11 Pro XL on 2026-09-15: a new module.prop beside a
# stale bin/hostctl), so installed module bytes are always re-staged from the
# payload through a mode-preserving overlay and proven on the phone against a
# payload-built checksum manifest, with files the payload no longer contains
# pruned. The overlay touches only the KernelSU module tree; the daemon
# runs from the separately managed /data/docker release tree.
verify_module_files() {
local work entry
stage 'Verifying the Pixel module bytes' 'Check the module verification output above; only the module tree was being restored, and the Docker daemon runs from the separate /data/docker release tree.'
work=$(mktemp -d "${TMPDIR:-/tmp}/eip-module.XXXXXX")
unzip -q "$PAYLOAD/host-module.zip" -d "$work/module" \
|| die 'the payload module archive cannot be extracted'
while IFS= read -r entry; do
[[ -n "$entry" ]] || continue
hash_file "$work/module/$entry"
printf '%s %s\n' "$FILE_SHA256" "$entry"
done < <(cd "$work/module" && LC_ALL=C find . -type f | LC_ALL=C sed 's|^\./||' | LC_ALL=C sort) \
> "$work/manifest"
LC_ALL=C awk '{ $1 = ""; sub(/^ /, ""); print }' "$work/manifest" > "$work/names"
tar -C "$work/module" -cf "$work/files.tar" .
push "$work/files.tar" /data/local/tmp/eip-module-files.tar
push "$work/manifest" /data/local/tmp/eip-module-manifest
push "$work/names" /data/local/tmp/eip-module-names
phone 'tar -xf /data/local/tmp/eip-module-files.tar -C /data/adb/modules/eip-pixel8a-forge && chown -R 0:0 /data/adb/modules/eip-pixel8a-forge && cd /data/adb/modules/eip-pixel8a-forge && /data/adb/ksu/bin/busybox sha256sum -c /data/local/tmp/eip-module-manifest -s && find . -type f | sed "s|^\\./||" | LC_ALL=C sort | LC_ALL=C comm -23 - /data/local/tmp/eip-module-names | while IFS= read -r stale; do case "$stale" in disable|remove|update|skip_mount) continue ;; esac; rm -f "$stale"; done; n_names=$(wc -l < /data/local/tmp/eip-module-names); n_files=$(find . -type f | wc -l); n_markers=0; for m in disable remove update skip_mount; do [ -f "$m" ] && n_markers=$((n_markers + 1)); done; if [ "$n_files" -ne $((n_names + n_markers)) ]; then printf 'module tree contains unexpected files beyond the payload and module-state markers\n' >&2; exit 5; fi; rm -f /data/local/tmp/eip-module-files.tar /data/local/tmp/eip-module-manifest /data/local/tmp/eip-module-names; exit 0' \
|| die 'installed module files do not match the payload'
rm -rf "$work"
}

# adb install has been observed to stall indefinitely when the installer runs
# without a terminal, so every APK install is bounded and retried once.
APK_INSTALL_TIMEOUT_SECONDS=180
Expand Down Expand Up @@ -440,7 +520,10 @@ if [[ "$HOST_MODULE_CURRENT" == false ]]; then
fi
stage 'Installing the Pixel Docker host' 'Check the module output above, package inputs, USB connection, and available phone storage.'
if [[ -f "$PAYLOAD/docker-engine.tgz" ]]; then
push "$PAYLOAD/docker-engine.tgz" /data/local/tmp/docker-29.8.0.tgz
bundled_engine_name=$(engine_archive_name) || die 'cannot read the pinned Docker Engine identity'
push "$PAYLOAD/docker-engine.tgz" "/data/local/tmp/$bundled_engine_name"
else
ensure_engine_archive
fi
push "$PAYLOAD/kernel.lz4" /data/local/tmp/Image-CP2A.260805.005.lz4
push "$PAYLOAD/host-module.zip" /data/local/tmp/eip-pixel8a-forge.zip
Expand All @@ -454,11 +537,16 @@ if [[ "$HOST_MODULE_CURRENT" == false ]]; then
case "$slot" in _a|_b) ;; *) die "cannot determine active slot: $slot" ;; esac
module_root=$(phone 'if test -x /data/adb/modules_update/eip-pixel8a-forge/bin/kernelctl; then printf /data/adb/modules_update/eip-pixel8a-forge; else printf /data/adb/modules/eip-pixel8a-forge; fi' | tr -d '\r')
phone "KSU=true KSU_VER=3.3.0 KSU_VER_CODE=33214 KSU_RUNTIME_MODE=lkm $module_root/bin/kernelctl install INSTALL:CP2A.260805.005:$slot"
phone 'rm -f /data/local/tmp/docker-29.8.0.tgz /data/local/tmp/Image-CP2A.260805.005.lz4 /data/local/tmp/eip-pixel8a-forge.zip'
engine_cleanup_name=$(engine_archive_name) || die 'cannot read the pinned Docker Engine identity'
phone "rm -f /data/local/tmp/$engine_cleanup_name /data/local/tmp/Image-CP2A.260805.005.lz4 /data/local/tmp/eip-pixel8a-forge.zip"
"$ADB_BIN" -s "$SERIAL" reboot >/dev/null 2>&1 || true
wait_android
verify_module_files
elif [[ "$EXISTING_INSTALL" == false ]]; then

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This elif [[ "$EXISTING_INSTALL" == false ]] branch (module already reports the target version, but the rest of the install — Docker disk/state/control app — isn't in place yet, e.g. a previous run got the module installed+rebooted and then failed/was interrupted before Docker setup completed) never calls verify_module_files. That is exactly the failure mode this PR exists to close: KernelSU can leave a fresh module.prop beside stale bin/hostctl/other module files, and boot-time module code (Wi-Fi routing, Docker startup) would run from that unproven tree here.

Contrast with the sibling branches that do verify:

  • line 541, right after the module is freshly installed+rebooted in this same if/elif.
  • lines 565–567, the existing-install park path.

This isn't just a theoretical gap — EXISTING_INSTALL="0" + HOST_MODULE_CURRENT="1" is the default test fixture combination (tests/simple-installer.test.mjs:422-423), so most of the existing suite already exercises this exact branch, and none of the new module-verification tests cover it. The README addition also states "Every update then proves the installed module tree against the payload," which isn't true for this path.

stage 'Preparing Docker storage' 'Check the host storage error above and select the existing disk size if this is a partial installation.'
if [[ -f "$PAYLOAD/host-module.zip" ]]; then
verify_module_files
fi
phone "/data/docker/bin/hostctl disk-init --size-bytes $DISK_BYTES"
fi

Expand All @@ -480,6 +568,9 @@ if [[ "$EXISTING_INSTALL" == true ]]; then
install_control_app
stage 'Waiting for current Forge work to finish' 'Forge remains available until its active work is idle; close new work and wait.'
wait_until_parked
if [[ -f "$PAYLOAD/host-module.zip" ]]; then
verify_module_files
fi
stage 'Restarting Docker for the update' 'Forge remains parked; check the Docker startup error above.'
start_docker
stage 'Installing the matched Forge update' 'Check the source transaction error above; existing state is retained for rollback.'
Expand Down
Loading