magisk-zapret2/tests/shell/packaging-recovery-flow.sh
2026-07-24 23:22:39 +03:00

382 lines
17 KiB
Shell

#!/bin/sh
set -eu
ROOT=$(CDPATH= cd -- "$(dirname "$0")/../.." && pwd)
TMP=${Z2_TEST_TMP:?}
CASE="$TMP/packaging-recovery"
STATE="$CASE/state"
AUDIT_MOD="$CASE/audit-module"
FIXTURE="$CASE/package"
PACKAGE_SOURCE="$CASE/package-source"
ARCHIVE="$CASE/module.zip"
MOCK="$CASE/bin"
LIVE=/data/adb/modules/zapret2
UPDATE=/data/adb/modules_update/zapret2
LIVE_STATE=/data/adb/zapret2-state
SYSTEM_CREATED=0
FIXTURE_OWNED=0
LIVE_TEST_PID=""
fail() { echo "FAIL: packaging recovery: $*" >&2; exit 1; }
cleanup() {
[ "$FIXTURE_OWNED" = 1 ] || return 0
if [ -n "$LIVE_TEST_PID" ]; then
kill "$LIVE_TEST_PID" 2>/dev/null || true
wait "$LIVE_TEST_PID" 2>/dev/null || true
fi
rm -rf "$LIVE" "$UPDATE" "$LIVE_STATE" "$CASE"
if [ "$SYSTEM_CREATED" = 1 ]; then
rm -f /system/bin/sh
rmdir /system/bin 2>/dev/null || true
rmdir /system 2>/dev/null || true
fi
}
# Destructive cleanup is armed only after a root-only, exact-target preflight.
# A failure before this point must never install a trap that removes paths the
# fixture has not proven it owns.
[ "$(id -u)" = 0 ] || fail "run as root"
for path in "$LIVE" "$UPDATE" "$LIVE_STATE" "$CASE"; do
[ ! -e "$path" ] && [ ! -L "$path" ] || fail "test path already exists: $path"
done
FIXTURE_OWNED=1
trap cleanup EXIT HUP INT TERM
mkdir -p /data/adb/modules /data/adb/modules_update "$CASE" "$STATE" "$AUDIT_MOD/zapret2" "$MOCK"
chmod 0700 "$STATE"
# Regression for trap ordering: a recursively invoked fixture must reject the
# already-existing target before arming cleanup, leaving foreign/preflight data
# untouched. The outer fixture owns and removes this sentinel afterward.
mkdir -p "$LIVE"
: > "$LIVE/preflight-sentinel"
if Z2_TEST_TMP="$TMP" sh "$0" >/dev/null 2>&1; then fail "recursive preflight unexpectedly accepted an existing target"; fi
[ -f "$LIVE/preflight-sentinel" ] || fail "failed preflight deleted an existing target"
rm -rf "$LIVE"
# Exercise the shared recovery-artifact classifier, including malformed and
# mixed evidence. These are behavioral checks, not source-string assertions.
STATE_DIR="$STATE"
ZAPRET_DIR="$AUDIT_MOD/zapret2"
MODDIR="$AUDIT_MOD"
SCRIPT_DIR="$ROOT/zapret2/scripts"
. "$ROOT/zapret2/scripts/common.sh"
audit_recovery_artifacts install || fail "clean install audit was blocked"
[ "$RECOVERY_ARTIFACT_CLASS" = clean ] || fail "clean audit class"
owner_start=$(awk '{print $22}' "/proc/$$/stat")
cat > "$FULL_ROLLBACK_TRANSACTION" <<EOF
version=1
module_dir=$MODDIR
token=partial-token
phase=armed
EOF
chmod 0600 "$FULL_ROLLBACK_TRANSACTION"
if audit_recovery_artifacts uninstall; then fail "partial rollback was accepted by uninstall"; fi
[ "$RECOVERY_ARTIFACT_CLASS" = rollback-partial ] || fail "partial rollback audit class"
rm -f "$FULL_ROLLBACK_TRANSACTION"
cat > "$FULL_ROLLBACK_META" <<EOF
version=1
module_dir=$MODDIR
token=done-token
generation=generation-one
archive_sha256=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
completed_epoch=1
complete=1
diagnostic=full rollback complete; reboot required
EOF
chmod 0600 "$FULL_ROLLBACK_META"
audit_recovery_artifacts uninstall || fail "valid completed rollback was blocked"
[ "$RECOVERY_ARTIFACT_CLASS" = rollback-complete ] || fail "completed rollback audit class"
# Completed rollback evidence is not a general lifecycle bypass. Only the
# exact live uninstall tombstone owner, bound to the active lifecycle lock, is
# admitted; install accepts only a canonical authenticated dead-owner tombstone.
if audit_recovery_artifacts lifecycle; then fail "completed rollback without tombstone admitted ordinary lifecycle"; fi
cat > "$UNINSTALL_TOMBSTONE" <<EOF
version=1
pid=$$
starttime=$owner_start
token=uninstall-owner-token
module_dir=$MODDIR
EOF
mkdir "$LIFECYCLE_LOCK"
cat > "$LIFECYCLE_LOCK_OWNER" <<EOF
pid=$$
starttime=$owner_start
token=uninstall-owner-token
EOF
chmod 0600 "$UNINSTALL_TOMBSTONE" "$LIFECYCLE_LOCK_OWNER"
ZAPRET2_UNINSTALL_TOKEN=uninstall-owner-token
ZAPRET2_UNINSTALL_OWNER_PID=$$
ZAPRET2_UNINSTALL_OWNER_START=$owner_start
export ZAPRET2_UNINSTALL_TOKEN ZAPRET2_UNINSTALL_OWNER_PID ZAPRET2_UNINSTALL_OWNER_START
audit_recovery_artifacts lifecycle || fail "exact active uninstall owner was blocked from completed rollback lifecycle"
if audit_recovery_artifacts install; then fail "active uninstall tombstone was accepted by install"; fi
[ "$RECOVERY_ARTIFACT_CLASS" = unsafe ] || fail "active tombstone was not classified unsafe"
unset ZAPRET2_UNINSTALL_TOKEN ZAPRET2_UNINSTALL_OWNER_PID ZAPRET2_UNINSTALL_OWNER_START
rm -rf "$LIFECYCLE_LOCK" "$UNINSTALL_TOMBSTONE"
cat > "$UNINSTALL_TOMBSTONE" <<EOF
version=1
pid=99999999
starttime=1
token=stale-uninstall-token
module_dir=$MODDIR
EOF
chmod 0600 "$UNINSTALL_TOMBSTONE"
audit_recovery_artifacts install || fail "stale authenticated canonical dead-owner tombstone was blocked"
[ "$RECOVERY_ARTIFACT_CLASS" = rollback-complete ] || fail "stale canonical tombstone changed rollback-complete classification"
rm -f "$UNINSTALL_TOMBSTONE"
cat > "$UNINSTALL_TOMBSTONE" <<EOF
version=1
pid=99999999
starttime=1
token=foreign-uninstall-token
module_dir=/data/adb/modules/foreign
EOF
chmod 0600 "$UNINSTALL_TOMBSTONE"
if audit_recovery_artifacts install; then fail "foreign uninstall tombstone was accepted"; fi
[ "$RECOVERY_ARTIFACT_CLASS" = unsafe ] || fail "foreign tombstone was not classified unsafe"
rm -f "$UNINSTALL_TOMBSTONE"
rm -f "$FULL_ROLLBACK_META"
ln -s missing-target "$FULL_ROLLBACK_META"
if audit_recovery_artifacts uninstall; then fail "unsafe symlink recovery artifact was accepted"; fi
[ "$RECOVERY_ARTIFACT_CLASS" = unsafe ] || fail "unsafe recovery audit class"
rm -f "$FULL_ROLLBACK_META"
# Build a realistic installer archive from the current package contract.
mkdir -p "$FIXTURE" "$PACKAGE_SOURCE"
cp "$ROOT/module.prop" "$ROOT/customize.sh" "$ROOT/service.sh" "$ROOT/uninstall.sh" \
"$ROOT/action.sh" "$PACKAGE_SOURCE/"
cp -R "$ROOT/system" "$ROOT/zapret2" "$PACKAGE_SOURCE/"
mkdir -p "$PACKAGE_SOURCE/zapret2/bin/arm64-v8a" "$PACKAGE_SOURCE/zapret2/bin/armeabi-v7a"
cp /bin/true "$PACKAGE_SOURCE/zapret2/bin/arm64-v8a/nfqws2"
cp "$PACKAGE_SOURCE/zapret2/bin/arm64-v8a/nfqws2" "$PACKAGE_SOURCE/zapret2/bin/armeabi-v7a/nfqws2"
printf '%s\n' b78b52c4cd7f843da3ff0848a3430afbd401bdf2 > "$PACKAGE_SOURCE/zapret2/upstream-zapret2.commit"
printf '%s\n' v0.8.1 > "$PACKAGE_SOURCE/zapret2/upstream-zapret2.release"
printf '%064d\n' 0 > "$PACKAGE_SOURCE/zapret2/upstream-zapret2.archive.sha256"
. "$PACKAGE_SOURCE/zapret2/scripts/package-contract.sh"
package_contract_assemble_package "$PACKAGE_SOURCE" "$FIXTURE" ||
fail "cannot assemble installer fixture: $PACKAGE_CONTRACT_CODE $PACKAGE_CONTRACT_DETAIL"
(cd "$FIXTURE" && zip -qr "$ARCHIVE" module.prop customize.sh service.sh uninstall.sh action.sh system zapret2)
run_installer() {
rm -rf "$UPDATE"
mkdir -p "$UPDATE"
unzip -oq "$ARCHIVE" customize.sh -d "$UPDATE"
(
MODPATH="$UPDATE"
ZIPFILE="$ARCHIVE"
BOOTMODE=true
ARCH=arm64
export MODPATH ZIPFILE BOOTMODE ARCH
abort() { echo "$*" >&2; rm -rf "$MODPATH"; exit 1; }
ui_print() { :; }
. "$UPDATE/customize.sh"
) || return $?
# Magisk removes installer-only files after customize.sh returns.
rm -f "$UPDATE/customize.sh"
}
# The installer is an Android program and invokes its validated package
# helpers through the platform shell. Establish that platform contract before
# the first installer run, not midway through the recovery scenario.
if [ ! -e /system ]; then
mkdir -p /system/bin
ln -s /bin/sh /system/bin/sh
SYSTEM_CREATED=1
elif [ ! -x /system/bin/sh ]; then
fail "/system exists without a usable /system/bin/sh"
fi
# A build/probe track is an ephemeral WAL owned by a serialized runtime
# lifecycle operation, not an installation ABI. Once the installer owns the
# exact lifecycle lock it retires a safe canonical track without parsing its
# old contents or requiring the interrupted firewall namespace to be empty.
cat > "$MOCK/iptables" <<'EOF'
#!/bin/sh
case "$*" in
*'-t mangle -S'*)
[ "${Z2_TEST_NAMESPACE:-0}" != 1 ] || printf '%s\n' '-N Z2O_AbCdEf1234'
exit 0
;;
*) exit 1 ;;
esac
EOF
cp "$MOCK/iptables" "$MOCK/ip6tables"
chmod 0755 "$MOCK/iptables" "$MOCK/ip6tables"
mkdir -p "$LIVE_STATE"
chmod 0700 "$LIVE_STATE"
printf '%s\n' 'malformed interrupted build evidence' > "$LIVE_STATE/build-track.ipv4.4115"
chmod 0600 "$LIVE_STATE/build-track.ipv4.4115"
Z2_TEST_NAMESPACE=1
export Z2_TEST_NAMESPACE
PATH="$MOCK:$PATH" run_installer || fail "fresh install was coupled to an opaque runtime track"
unset Z2_TEST_NAMESPACE
[ -f "$LIVE_STATE/build-track.ipv4.4115" ] ||
fail "fresh staging mutated unrelated runtime tracking"
[ -f "$UPDATE/zapret2/install-generation.meta" ] || fail "fresh install generation was not published"
grep -Eq '^archive_sha256=[0-9a-f]{64}$' "$UPDATE/zapret2/install-generation.meta" || fail "archive hash is invalid"
[ ! -e "$UPDATE/customize.sh" ] || fail "installer-only customize.sh remained in the installed shape"
rm -f "$LIVE_STATE/build-track.ipv4.4115"
mv "$UPDATE" "$LIVE"
# A root-manager disable fence on a complete live module must not cross into a
# standard modules_update generation. Reproduce the real device
# failure: a dead same-boot start left unfinished IPv4/IPv6 WAL records and
# partial firewall objects. Their contents belong to the old runtime lifecycle,
# so they must not gate staging the new module.
track_boot=$(cat /proc/sys/kernel/random/boot_id)
for track_family in ipv4 ipv6; do
track_tool=iptables
track_tag=e4755ceb92
[ "$track_family" = ipv4 ] || { track_tool=ip6tables; track_tag=f4755ceb92; }
cat > "$LIVE_STATE/build-track.$track_family.99999997" <<EOF
version=2
mode=build
tool=$track_tool
module_dir=$LIVE
creator_pid=99999997
creator_starttime=1
boot_id=$track_boot
record|1|applied|chain|Z2O_$track_tag
record|2|applied|chain|Z2I_$track_tag
record|3|applied|chain|Z2R_${track_tag}_O1
record|4|applied|anchor|Z2O_$track_tag|Z2R_${track_tag}_O1
record|5|pending|rule|Z2R_${track_tag}_O1|tcp|out|80:65535|20|original|200|0x40000000|1|1|1
EOF
chmod 0600 "$LIVE_STATE/build-track.$track_family.99999997"
done
: > "$LIVE/disable"
chmod 0600 "$LIVE/disable"
Z2_TEST_NAMESPACE=1
export Z2_TEST_NAMESPACE
PATH="$MOCK:$PATH" run_installer || fail "clean staging failed while live runtime state existed"
unset Z2_TEST_NAMESPACE
[ -f "$LIVE_STATE/build-track.ipv4.99999997" ] ||
fail "package staging mutated live IPv4 runtime evidence"
[ -f "$LIVE_STATE/build-track.ipv6.99999997" ] ||
fail "package staging mutated live IPv6 runtime evidence"
[ ! -e "$UPDATE/disable" ] && [ ! -L "$UPDATE/disable" ] ||
fail "old disable marker crossed the release boundary"
[ -f "$UPDATE/zapret2/install-generation.meta" ] || fail "install generation was not published"
grep -Eq '^archive_sha256=[0-9a-f]{64}$' "$UPDATE/zapret2/install-generation.meta" || fail "archive hash is invalid"
rm -f "$LIVE_STATE/build-track.ipv4.99999997" "$LIVE_STATE/build-track.ipv6.99999997"
# Promote the staged tree, complete an actual full rollback, uninstall it, then
# simulate root-manager removal and reinstall. Completed generation/hosts
# evidence must be retired; the uninstall tombstone must be authenticated and
# cleared by the reinstall; the old disable fence must not resurrect.
rm -rf "$LIVE"
mv "$UPDATE" "$LIVE"
mkdir -p "$LIVE/system/etc" "$LIVE_STATE"
chmod 0700 "$LIVE_STATE"
# A standard Magisk update is staged below modules_update while the currently
# installed service can remain live. Current owner metadata must authenticate against the
# exact packaged live path, never the candidate/staging binary, and the
# installer must neither stop nor rewrite that live publication.
rm -f "$LIVE/disable"
cp "$LIVE/zapret2/nfqws2" "$CASE/nfqws2.packaged"
cp /bin/sh "$LIVE/zapret2/nfqws2"
chmod 0755 "$LIVE/zapret2/nfqws2"
"$LIVE/zapret2/nfqws2" -c 'while :; do sleep 1; done' --qnum=200 &
LIVE_TEST_PID=$!
sleep 1
kill -0 "$LIVE_TEST_PID" 2>/dev/null || fail "live owner fixture process did not remain running"
(
STATE_DIR="$LIVE_STATE"
MODDIR="$LIVE"
ZAPRET_DIR="$LIVE/zapret2"
SCRIPT_DIR="$LIVE/zapret2/scripts"
export STATE_DIR MODDIR ZAPRET_DIR SCRIPT_DIR
. "$LIVE/zapret2/scripts/common.sh"
QNUM=200; PORTS_TCP=80,443; PORTS_UDP=443; TCP_PKT_OUT=20; TCP_PKT_IN=10; UDP_PKT_OUT=20; UDP_PKT_IN=10; PKT_OUT=20; PKT_IN=10; DESYNC_MARK=0x40000000
FIREWALL_TAG=AbCdEf1234; ZAPRET2_OUT=Z2O_AbCdEf1234; ZAPRET2_IN=Z2I_AbCdEf1234
IPV4_CONNBYTES=1; IPV4_MULTIPORT=1; IPV4_MARK=1
IPV6_CONNBYTES=1; IPV6_MULTIPORT=1; IPV6_MARK=1
prepare_owner_generation_spec 1 0 || exit 41
owner_starttime=$(proc_starttime "$LIVE_TEST_PID") || exit 42
owner_argv_sha256=$(proc_cmdline_sha256 "$LIVE_TEST_PID") || exit 43
write_owner_state "$LIVE_TEST_PID" "$owner_starttime" "$owner_argv_sha256" 200 running-modules-update active || exit 44
write_numeric_pidfile "$LIVE_TEST_PID" || exit 45
) || fail "could not publish exact live owner v8 fixture"
grep -Fxq "exe=$LIVE/zapret2/nfqws2" "$LIVE_STATE/owner.meta" || fail "live owner fixture used a non-canonical exe"
cp "$LIVE_STATE/owner.meta" "$CASE/running-owner.before"
run_installer || fail "standard modules_update install rejected a valid running live owner"
kill -0 "$LIVE_TEST_PID" 2>/dev/null || fail "standard modules_update install stopped the live service"
cmp -s "$CASE/running-owner.before" "$LIVE_STATE/owner.meta" || fail "standard modules_update install rewrote live owner metadata"
[ -d "$UPDATE" ] && [ ! -L "$UPDATE" ] || fail "running modules_update candidate was not staged"
kill "$LIVE_TEST_PID" 2>/dev/null || fail "could not stop live owner test process"
wait "$LIVE_TEST_PID" 2>/dev/null || true
LIVE_TEST_PID=""
rm -f "$LIVE_STATE/owner.meta" "$LIVE_STATE/nfqws2.pid"
cp "$CASE/nfqws2.packaged" "$LIVE/zapret2/nfqws2"
chmod 0755 "$LIVE/zapret2/nfqws2"
rm -rf "$UPDATE"
printf '%s\n' '127.0.0.1 localhost' '1.1.1.1 preserved.test' > "$LIVE/system/etc/hosts"
chmod 0644 "$LIVE/system/etc/hosts"
cat > "$MOCK/iptables" <<'EOF'
#!/bin/sh
case "$*" in
*' -L OUTPUT -n') exit 0 ;;
*' -F '*|*' -X '*|*' -D '*) exit 0 ;;
*'-S ZAPRET2_OUT'|*'-S ZAPRET2_IN'|*'-S ZAPRET2_PROBE') exit 1 ;;
*'-S OUTPUT'|*'-S INPUT') exit 0 ;;
*'-S') exit 0 ;;
*) exit 1 ;;
esac
EOF
cp "$MOCK/iptables" "$MOCK/ip6tables"
chmod 0755 "$MOCK/iptables" "$MOCK/ip6tables"
PATH="$MOCK:$PATH" STATE_DIR="$LIVE_STATE" sh "$LIVE/zapret2/scripts/zapret-full-rollback.sh" --machine > "$CASE/rollback.out" || fail "full rollback failed"
grep -Fxq 'Z2_RB_STATUS=complete' "$CASE/rollback.out" || fail "rollback did not complete"
[ -f "$LIVE_STATE/full-rollback.meta" ] && [ -f "$LIVE_STATE/hosts.rollback.backup" ] || fail "rollback evidence missing"
printf '%s\n' 'foreign state must survive' > "$LIVE_STATE/unknown.child"
chmod 0600 "$LIVE_STATE/unknown.child"
set +e
PATH="$MOCK:$PATH" MODPATH="$LIVE" sh "$LIVE/uninstall.sh" > "$CASE/uninstall-partial.out" 2>&1
partial_uninstall_rc=$?
set -e
[ "$partial_uninstall_rc" = 1 ] || fail "uninstall reported success while preserving an unknown state child"
[ -f "$LIVE_STATE/unknown.child" ] || fail "uninstall deleted an unknown state child"
[ -f "$LIVE_STATE/uninstall.tombstone" ] || fail "partial uninstall did not preserve its start gate"
grep -Fq 'uninstall cleanup is partial' "$CASE/uninstall-partial.out" || fail "partial uninstall did not report an explicit partial result"
if grep -Fq 'stopped, verified clean, and uninstalled' "$CASE/uninstall-partial.out"; then
fail "partial uninstall emitted the full-success message"
fi
rm -f "$LIVE_STATE/unknown.child"
PATH="$MOCK:$PATH" MODPATH="$LIVE" sh "$LIVE/uninstall.sh" > "$CASE/uninstall.out" || fail "uninstall retry after removing unknown state failed"
[ ! -e "$LIVE_STATE/full-rollback.meta" ] && [ ! -e "$LIVE_STATE/hosts.rollback.backup" ] || fail "completed rollback generation was not retired"
[ -f "$LIVE_STATE/uninstall.tombstone" ] || fail "uninstall tombstone missing"
rm -rf "$LIVE"
run_installer || fail "reinstall after rollback/uninstall failed"
[ ! -e "$UPDATE/disable" ] && [ ! -L "$UPDATE/disable" ] || fail "stale rollback disable marker resurrected"
[ -f "$LIVE_STATE/uninstall.tombstone" ] ||
fail "clean package staging mutated independent uninstall state"
# Magisk's Delete button publishes the durable remove marker and invokes
# uninstall.sh at the next boot before deleting the module directory. That
# explicit authority must purge the whole private state tree even when an old
# interrupted build journal is malformed; process/firewall cleanup is still
# verified independently first.
mv "$UPDATE" "$LIVE"
: > "$LIVE/remove"
chmod 0600 "$LIVE/remove"
printf '%s\n' 'malformed interrupted build evidence' > "$LIVE_STATE/build-track.ipv4.4115"
chmod 0600 "$LIVE_STATE/build-track.ipv4.4115"
PATH="$MOCK:$PATH" MODPATH="$LIVE" sh "$LIVE/uninstall.sh" > "$CASE/magisk-remove.out" ||
fail "Magisk removal path did not force-clean private state"
[ ! -e "$LIVE_STATE" ] && [ ! -L "$LIVE_STATE" ] ||
fail "Magisk removal left the private state directory"
grep -Fq 'all Zapret2 service, firewall, and private state was removed' "$CASE/magisk-remove.out" ||
fail "Magisk removal did not report full private-state cleanup"
echo "Packaging recovery flow tests passed"