magisk-zapret2/tests/shell/purge-contract.sh
loop-uh 0e0723fb55 Measure the removal facts on the receipt uninstall failure prints too
uninstall.sh's manager-remove branch clears the external workspaces and
the whole private state tree before its final steps can still fail, so a
receipt that hardcodes every removal fact to zero denies removals that
already happened — on the record the app reads to decide what the device
still holds. This was the one partial receipt the previous assertion did
not cover; it now covers both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 11:25:15 +03:00

129 lines
7.4 KiB
Shell
Executable file

#!/bin/sh
set -eu
ROOT=$(CDPATH= cd -- "$(dirname "$0")/../.." && pwd)
CONTRACT="$ROOT/zapret2/scripts/lifecycle/purge-contract.sh"
PURGE="$ROOT/zapret2/scripts/lifecycle/zapret-purge.sh"
ACTION="$ROOT/action.sh"
fail() { echo "FAIL: purge-contract: $*" >&2; exit 1; }
assert_contains() { grep -Fq -- "$2" "$1" || fail "missing contract fragment: $2"; }
[ -f "$CONTRACT" ] && [ ! -L "$CONTRACT" ] && [ -x "$CONTRACT" ] ||
fail "purge contract is not a regular executable"
[ -f "$PURGE" ] && [ ! -L "$PURGE" ] && [ -x "$PURGE" ] ||
fail "purge entry is not a regular executable"
[ "$(sed -n '1p' "$CONTRACT")" = '#!/system/bin/sh' ] || fail "contract shebang"
[ "$(sed -n '1p' "$PURGE")" = '#!/system/bin/sh' ] || fail "purge shebang"
# Pure allowlist/token helpers can be exercised without touching Android paths.
STATE_DIR=/data/adb/zapret2-state
MODDIR=/data/adb/modules/zapret2
is_valid_boot_id() { case "$1" in ""|*[!A-Za-z0-9._-]*) return 1 ;; *) return 0 ;; esac; }
read_current_boot_id() { CURRENT_BOOT_ID=test-boot; }
. "$CONTRACT"
z2_purge_is_safe_token app.1234.safe-token || fail "safe token rejected"
if z2_purge_is_safe_token '../escape'; then fail "unsafe token accepted"; fi
z2_purge_managed_tree_path /data/adb/modules/zapret2 || fail "canonical module path rejected"
z2_purge_managed_tree_path /data/adb/modules_update/zapret2 || fail "pending module path rejected"
z2_purge_managed_tree_path /data/adb/zapret2-recovery.test || fail "recovery workspace rejected"
if z2_purge_managed_tree_path /data/adb/modules/zapret2-copy; then
fail "module prefix was accepted as the canonical module"
fi
if z2_purge_managed_tree_path /data/adb/zapret2-install.test/escape; then
fail "nested path was accepted as an installer workspace"
fi
if z2_purge_managed_tree_path /data/adb/modules/.zapret2-recovery-../escape; then
fail "unsafe recovery workspace suffix was accepted"
fi
if z2_purge_managed_tree_path /data/adb/modules; then fail "broad module root accepted"; fi
if z2_purge_managed_tree_path /data/adb; then fail "broad adb root accepted"; fi
# The irreversible path is one implementation shared by APK and root managers, and
# the APK-preservation bit is produced by the script rather than trusted input.
assert_contains "$ACTION" 'zapret2/scripts/lifecycle/zapret-purge.sh'
assert_contains "$ACTION" 'exec /system/bin/sh "$PURGE_SCRIPT" --manager-action'
assert_contains "$PURGE" 'Z2_PURGE_APK_TOUCHED=0'
assert_contains "$PURGE" 'remove_request_if_exact ||'
assert_contains "$PURGE" 'publish_remove_marker ||'
assert_contains "$PURGE" '/system/bin/sh "$UNINSTALL_SCRIPT"'
assert_contains "$PURGE" 'z2_purge_remove_external_workspaces'
assert_contains "$PURGE" 'z2_purge_remove_legacy_files'
assert_contains "$PURGE" 'z2_purge_remove_managed_tree "$Z2_PURGE_CANONICAL_PENDING_DIR"'
assert_contains "$PURGE" 'z2_purge_remove_managed_tree "$Z2_PURGE_CANONICAL_MODULE_DIR"'
assert_contains "$PURGE" 'z2_purge_remove_managed_tree "$Z2_PURGE_CANONICAL_STATE_DIR"'
assert_contains "$PURGE" 'Z2_PURGE_REBOOT_REQUIRED=%s'
assert_contains "$PURGE" 'pm clear --user "$user" "$package"'
consume_line=$(grep -nF 'remove_request_if_exact ||' "$PURGE" | head -n 1 | cut -d: -f1)
marker_line=$(grep -nF 'publish_remove_marker ||' "$PURGE" | head -n 1 | cut -d: -f1)
module_line=$(grep -nF 'z2_purge_remove_managed_tree "$Z2_PURGE_CANONICAL_MODULE_DIR"' "$PURGE" | head -n 1 | cut -d: -f1)
state_line=$(grep -nF 'z2_purge_remove_managed_tree "$Z2_PURGE_CANONICAL_STATE_DIR"' "$PURGE" | head -n 1 | cut -d: -f1)
uninstall_line=$(grep -nF '/system/bin/sh "$UNINSTALL_SCRIPT"' "$PURGE" | head -n 1 | cut -d: -f1)
[ "$consume_line" -lt "$uninstall_line" ] || fail "one-time request is not consumed before uninstall"
# The removal fence commits the twice-confirmed purge before any destruction:
# it blocks concurrent starts and routes uninstall.sh onto the manager-remove
# branch. If cleanup fails here, the fenced module is retired at next boot.
[ "$marker_line" -lt "$uninstall_line" ] || fail "removal fence is not published before uninstall delegates cleanup"
[ "$module_line" -lt "$state_line" ] || fail "state directory is not the final managed tree removed"
grep -Fq '"$PURGE_REQUEST"' "$ROOT/uninstall.sh" ||
fail "normal uninstall does not retire an abandoned purge request"
# Z2_PURGE_STATUS=complete is a contract the app enforces: it asserts every
# other field is affirmative, and a receipt that reports complete alongside an
# unverified firewall is rejected wholesale as a protocol violation. Every
# emitted receipt must therefore satisfy the contract it declares.
awk '
/purge_report complete/ {
for (i = 1; i <= NF; i++) if ($i == "complete") break
for (j = i + 1; j <= i + 6; j++) if ($j != 1) { print NR ": " $0; bad = 1 }
}
END { exit bad ? 1 : 0 }
' "$PURGE" || fail "a complete purge receipt reports a fact it did not verify"
# The unverifiable-IPv6 hand-off between uninstall and purge is a string match
# across a process boundary: uninstall prints a warning, purge pattern-matches
# its captured output. Grepping each side for the token separately passes even
# when the pattern cannot match what the other side actually prints, so take
# the real emitted message and the real case pattern and run one against the
# other.
ipv6_warning="$(sed -n 's/.*report_warning "\(Z2_FIREWALL_IPV6_UNVERIFIED[^"]*\)".*/\1/p' \
"$ROOT/uninstall.sh" | head -n 1)"
[ -n "$ipv6_warning" ] || fail "uninstall no longer emits an unverifiable-IPv6 warning"
ipv6_pattern="$(sed -n 's/^[[:space:]]*\(\*Z2_FIREWALL_IPV6_UNVERIFIED\*\)).*/\1/p' \
"$PURGE" | head -n 1)"
[ -n "$ipv6_pattern" ] || fail "purge no longer pattern-matches an unverifiable IPv6 ruleset"
ipv6_matched=0
eval "case \"\$ipv6_warning\" in $ipv6_pattern) ipv6_matched=1 ;; esac"
[ "$ipv6_matched" = 1 ] || fail "purge cannot recognize the warning uninstall actually emits"
# A partial receipt reports what survived. Denying a removal that did happen
# is as wrong as claiming one that did not: an unremovable external workspace
# must not report the module directory and private state as still present.
if grep -Eq 'purge_report partial [01] [01] 0 0 0 1' "$PURGE"; then
fail "a partial purge receipt hardcodes removal facts it did not measure"
fi
grep -Fq 'z2_purge_external_workspaces_absent' "$PURGE" ||
fail "a partial receipt no longer measures external workspaces it did not remove itself"
grep -Fq 'purge_report partial 1 0 "$module_removed" "$state_removed" "$external_removed" 1' "$PURGE" ||
fail "the unverified-firewall partial receipt no longer reports measured removal facts"
grep -Fq 'purge_report partial 1 1 "$module_removed" "$state_removed" "$external_removed" 1' "$PURGE" ||
fail "the clean-firewall partial receipt no longer reports measured removal facts"
# Every receipt that survives an actually-completed removal must still tell
# callers a reboot is owed, and the one partial receipt that returns success
# is the removal-succeeded/IPv6-unproven case — the app reads it as "the
# module is gone", so its removal fields must all be affirmative.
awk '
/purge_report partial/ {
for (i = 1; i <= NF; i++) if ($i == "partial") break
if ($(i + 6) != 1) { print NR ": " $0; bad = 1 }
}
END { exit bad ? 1 : 0 }
' "$PURGE" || fail "a partial purge receipt does not require the pending reboot"
grep -Fq 'purge_report partial 1 0 1 1 1 1' "$PURGE" ||
fail "the removal-succeeded/IPv6-unproven receipt no longer reports the module as removed"
echo "Purge contract shell tests passed"