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>
129 lines
7.4 KiB
Shell
Executable file
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"
|