magisk-zapret2/zapret2/scripts/lifecycle/zapret-purge.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

310 lines
16 KiB
Shell
Executable file

#!/system/bin/sh
# One-shot irreversible module cleanup shared by the APK, CLI and root-manager Action.
umask 077
LIFECYCLE_DIR="$(CDPATH=; cd "$(dirname "$0")" 2>/dev/null && pwd -P)" || exit 1
SCRIPT_DIR="$(dirname "$LIFECYCLE_DIR")"
ZAPRET_DIR="$(dirname "$SCRIPT_DIR")"
MODDIR="$(dirname "$ZAPRET_DIR")"
COMMON_SCRIPT="$SCRIPT_DIR/common.sh"
PURGE_CONTRACT="$LIFECYCLE_DIR/purge-contract.sh"
UNINSTALL_SCRIPT="$MODDIR/uninstall.sh"
MODULE_PROP="$MODDIR/module.prop"
[ -d "$MODDIR" ] && [ ! -L "$MODDIR" ] &&
[ -f "$MODULE_PROP" ] && [ ! -L "$MODULE_PROP" ] &&
[ "$(grep -c '^id=' "$MODULE_PROP" 2>/dev/null)" = 1 ] &&
grep -qx 'id=zapret2' "$MODULE_PROP" || {
echo "ERROR: purge module identity is unavailable" >&2
exit 1
}
for required in "$COMMON_SCRIPT" "$PURGE_CONTRACT" "$UNINSTALL_SCRIPT"; do
[ -f "$required" ] && [ ! -L "$required" ] || { echo "ERROR: purge dependency is unavailable" >&2; exit 1; }
done
STATE_DIR="/data/adb/zapret2-state"
. "$COMMON_SCRIPT" || exit 1
. "$PURGE_CONTRACT" || exit 1
PURGE_REQUEST="$Z2_PURGE_REQUEST"
export PURGE_REQUEST
Z2_PURGE_MACHINE=0
Z2_PURGE_DIAGNOSTIC=""
purge_report() {
local status="$1" process_clean="$2" firewall_clean="$3" module_removed="$4"
local state_removed="$5" external_removed="$6" reboot_required="$7" diagnostic="$8"
diagnostic="$(printf '%s' "$diagnostic" | tr '\r\n' ' ' | cut -c 1-1024)"
if [ "$Z2_PURGE_MACHINE" = 1 ]; then
printf 'Z2_PURGE_VERSION=%s\n' "$Z2_PURGE_PROTOCOL_VERSION"
printf 'Z2_PURGE_STATUS=%s\n' "$status"
printf 'Z2_PURGE_PROCESS_CLEAN=%s\n' "$process_clean"
printf 'Z2_PURGE_FIREWALL_CLEAN=%s\n' "$firewall_clean"
printf 'Z2_PURGE_MODULE_REMOVED=%s\n' "$module_removed"
printf 'Z2_PURGE_STATE_REMOVED=%s\n' "$state_removed"
printf 'Z2_PURGE_EXTERNAL_REMOVED=%s\n' "$external_removed"
printf 'Z2_PURGE_APK_TOUCHED=0\n'
printf 'Z2_PURGE_REBOOT_REQUIRED=%s\n' "$reboot_required"
printf 'Z2_PURGE_DIAGNOSTIC=%s\n' "$diagnostic"
printf 'Z2_PURGE_COMPLETE=1\n'
else
printf '%s\n' "$diagnostic"
fi
}
purge_prepare_report() {
local status="$1" token="$2" diagnostic="$3"
diagnostic="$(printf '%s' "$diagnostic" | tr '\r\n' ' ' | cut -c 1-512)"
if [ "$Z2_PURGE_MACHINE" = 1 ]; then
printf 'Z2_PURGE_PREPARE_VERSION=%s\n' "$Z2_PURGE_PROTOCOL_VERSION"
printf 'Z2_PURGE_PREPARE_STATUS=%s\n' "$status"
printf 'Z2_PURGE_PREPARE_TOKEN=%s\n' "$token"
printf 'Z2_PURGE_PREPARE_DIAGNOSTIC=%s\n' "$diagnostic"
printf 'Z2_PURGE_PREPARE_COMPLETE=1\n'
else
printf '%s\n' "$diagnostic"
fi
}
remove_request_if_exact() {
[ -e "$Z2_PURGE_REQUEST" ] || [ -L "$Z2_PURGE_REQUEST" ] || return 0
z2_purge_request_is_secure || return 1
rm -f "$Z2_PURGE_REQUEST" 2>/dev/null || return 1
[ ! -e "$Z2_PURGE_REQUEST" ] && [ ! -L "$Z2_PURGE_REQUEST" ]
}
prepare_purge() {
local source="$1" token created tmp
case "$source" in app|manager|cli) ;; *) purge_prepare_report error "" "invalid purge source"; return 1 ;; esac
[ "$(id -u 2>/dev/null)" = 0 ] || { purge_prepare_report blocked "" "root access is required"; return 1; }
z2_purge_module_identity_is_exact "$MODDIR" || { purge_prepare_report blocked "" "installed module identity is unsafe"; return 1; }
ensure_state_dir && z2_purge_state_dir_is_secure || { purge_prepare_report error "" "secure purge state is unavailable"; return 1; }
if [ -e "$FULL_ROLLBACK_TRANSACTION" ] || [ -L "$FULL_ROLLBACK_TRANSACTION" ]; then
purge_prepare_report blocked "" "another update or rollback transaction is active"
return 1
fi
if [ -e "$UNINSTALL_TOMBSTONE" ] || [ -L "$UNINSTALL_TOMBSTONE" ]; then
state_file_is_secure "$UNINSTALL_TOMBSTONE" && read_uninstall_tombstone &&
[ "$UNINSTALL_FILE_MODULE" = "$MODDIR" ] && ! uninstall_tombstone_owner_alive || {
purge_prepare_report blocked "" "uninstall evidence is active, malformed, or unsafe"
return 1
}
fi
if [ -e "$MODDIR/remove" ] || [ -L "$MODDIR/remove" ]; then
[ -f "$MODDIR/remove" ] && [ ! -L "$MODDIR/remove" ] &&
z2_purge_path_uid_is_root "$MODDIR/remove" && path_mode_is_0600 "$MODDIR/remove" &&
[ "$(wc -c < "$MODDIR/remove" 2>/dev/null)" = 0 ] || {
purge_prepare_report blocked "" "module removal marker is unsafe"
return 1
}
fi
if [ -e "$Z2_PURGE_REQUEST" ] || [ -L "$Z2_PURGE_REQUEST" ]; then
if z2_purge_request_is_live; then
purge_prepare_report blocked "" "another irreversible purge confirmation is already armed"
return 1
fi
remove_request_if_exact || { purge_prepare_report blocked "" "stale purge request is unsafe"; return 1; }
fi
token="$(new_lifecycle_token)" || { purge_prepare_report error "" "cannot create one-time purge token"; return 1; }
z2_purge_is_safe_token "$token" || { purge_prepare_report error "" "generated purge token is invalid"; return 1; }
created="$(date +%s 2>/dev/null)" || { purge_prepare_report error "" "cannot read confirmation time"; return 1; }
z2_purge_is_decimal "$created" && read_current_boot_id || {
purge_prepare_report error "" "cannot bind confirmation to the current boot"
return 1
}
tmp="$Z2_PURGE_REQUEST.tmp.$$.$token"
state_path_is_managed_file "$tmp" && [ ! -e "$tmp" ] && [ ! -L "$tmp" ] || {
purge_prepare_report error "" "one-time confirmation target is unsafe"
return 1
}
{
printf 'version=%s\n' "$Z2_PURGE_PROTOCOL_VERSION"
printf 'source=%s\n' "$source"
printf 'token=%s\n' "$token"
printf 'created_epoch=%s\n' "$created"
printf 'boot_id=%s\n' "$CURRENT_BOOT_ID"
printf 'module_dir=%s\n' "$Z2_PURGE_CANONICAL_MODULE_DIR"
} > "$tmp" || { rm -f "$tmp"; purge_prepare_report error "" "cannot write one-time confirmation"; return 1; }
chmod 0600 "$tmp" 2>/dev/null || {
rm -f "$tmp"
purge_prepare_report error "" "cannot secure one-time confirmation"
return 1
}
mv -f "$tmp" "$Z2_PURGE_REQUEST" 2>/dev/null || {
rm -f "$tmp"
purge_prepare_report error "" "cannot publish one-time confirmation"
return 1
}
if ! sync >/dev/null 2>&1; then
remove_request_if_exact >/dev/null 2>&1 || true
purge_prepare_report error "" "one-time confirmation was not durable"
return 1
fi
if ! z2_purge_request_is_live || [ "$Z2_PURGE_REQUEST_SOURCE" != "$source" ] ||
[ "$Z2_PURGE_REQUEST_TOKEN" != "$token" ]; then
remove_request_if_exact >/dev/null 2>&1 || true
purge_prepare_report error "" "one-time confirmation verification failed"
return 1
fi
purge_prepare_report armed "$token" "irreversible purge armed for 120 seconds"
}
publish_remove_marker() {
local marker="$MODDIR/remove" tmp="$MODDIR/.remove.purge.$$.$Z2_PURGE_REQUEST_TOKEN"
if [ -e "$marker" ] || [ -L "$marker" ]; then
[ -f "$marker" ] && [ ! -L "$marker" ] && z2_purge_path_uid_is_root "$marker" &&
[ "$(wc -c < "$marker" 2>/dev/null)" = 0 ] || return 1
return 0
fi
[ ! -e "$tmp" ] && [ ! -L "$tmp" ] || return 1
: > "$tmp" || return 1
chmod 0600 "$tmp" 2>/dev/null || { rm -f "$tmp"; return 1; }
mv "$tmp" "$marker" 2>/dev/null || { rm -f "$tmp"; return 1; }
[ -f "$marker" ] && [ ! -L "$marker" ] && [ "$(wc -c < "$marker" 2>/dev/null)" = 0 ]
}
commit_purge() {
local source="$1" token="$2" uninstall_output uninstall_rc=0 cleanup_rc=0 firewall_clean=1
local external_removed=1 module_removed=0 state_removed=0 residue=""
case "$source" in app|manager|cli) ;; *) purge_report error 0 0 0 0 0 0 "invalid purge source"; return 1 ;; esac
z2_purge_is_safe_token "$token" || { purge_report error 0 0 0 0 0 0 "invalid purge token"; return 1; }
[ "$(id -u 2>/dev/null)" = 0 ] || { purge_report blocked 0 0 0 0 0 0 "root access is required"; return 1; }
z2_purge_request_is_live && [ "$Z2_PURGE_REQUEST_SOURCE" = "$source" ] &&
[ "$Z2_PURGE_REQUEST_TOKEN" = "$token" ] || {
purge_report blocked 0 0 0 0 0 0 "purge confirmation is missing, expired, or belongs to another caller"
return 1
}
remove_request_if_exact || { purge_report error 0 0 0 0 0 0 "one-time purge request could not be consumed"; return 1; }
sync >/dev/null 2>&1 || { purge_report error 0 0 0 0 0 0 "purge request retirement was not durable"; return 1; }
z2_purge_module_identity_is_exact "$MODDIR" || { purge_report blocked 0 0 0 0 0 0 "installed module identity changed"; return 1; }
# Publish the durable removal fence first: it blocks any concurrent start
# from this point on and routes uninstall.sh onto its manager-remove
# branch, which destroys the whole private state tree in one verified
# pass instead of the granular tombstone path.
publish_remove_marker || { purge_report error 0 0 0 0 0 0 "cannot publish the permanent module-removal gate"; return 1; }
uninstall_output="$(MODPATH="$MODDIR" /system/bin/sh "$UNINSTALL_SCRIPT" 2>&1)" || uninstall_rc=$?
if [ "$uninstall_rc" -ne 0 ]; then
# The fence is already published, so this is not a "nothing happened"
# rejection: the module is retired at the next boot either way. And it
# is not a "nothing was removed" one either — uninstall.sh's
# manager-remove branch clears the external workspaces and the entire
# private state tree before its last steps can still fail, so these
# facts are measured here for the same reason they are measured below.
{ [ -e "$Z2_PURGE_CANONICAL_MODULE_DIR" ] || [ -L "$Z2_PURGE_CANONICAL_MODULE_DIR" ]; } || module_removed=1
{ [ -e "$Z2_PURGE_CANONICAL_STATE_DIR" ] || [ -L "$Z2_PURGE_CANONICAL_STATE_DIR" ]; } || state_removed=1
z2_purge_external_workspaces_absent || external_removed=0
purge_report partial 0 0 "$module_removed" "$state_removed" "$external_removed" 1 \
"verified service/firewall uninstall failed: $uninstall_output"
return 1
fi
z2_purge_remove_managed_tree "$Z2_PURGE_CANONICAL_PENDING_DIR" || cleanup_rc=1
z2_purge_remove_external_workspaces || { cleanup_rc=1; external_removed=0; }
z2_purge_remove_legacy_files || cleanup_rc=1
z2_purge_remove_managed_tree "$Z2_PURGE_CANONICAL_MODULE_DIR" || cleanup_rc=1
z2_purge_remove_managed_tree "$Z2_PURGE_CANONICAL_STATE_DIR" || cleanup_rc=1
sync >/dev/null 2>&1 || cleanup_rc=1
# uninstall.sh may legitimately leave an unverifiable IPv6 family to the
# pending reboot. That is not a clean firewall, and the receipt must not
# claim otherwise just because removal itself succeeded.
firewall_clean=1
case "$uninstall_output" in
*Z2_FIREWALL_IPV6_UNVERIFIED*) firewall_clean=0 ;;
esac
# Any failure above lands here, including ones that left both managed trees
# gone — an unremovable external staging workspace is enough. Reporting a
# blanket "nothing was removed" would deny a removal that did happen, on
# the same receipt the app reads to decide what the device still holds. So
# each fact is measured, and the diagnostic names what actually survived.
{ [ -e "$Z2_PURGE_CANONICAL_MODULE_DIR" ] || [ -L "$Z2_PURGE_CANONICAL_MODULE_DIR" ]; } || module_removed=1
{ [ -e "$Z2_PURGE_CANONICAL_STATE_DIR" ] || [ -L "$Z2_PURGE_CANONICAL_STATE_DIR" ]; } || state_removed=1
if [ "$cleanup_rc" -ne 0 ] || [ "$module_removed" = 0 ] || [ "$state_removed" = 0 ]; then
[ "$module_removed" = 1 ] || residue="module directory"
[ "$state_removed" = 1 ] || residue="${residue:+$residue, }private state"
[ "$external_removed" = 1 ] || residue="${residue:+$residue, }external staging workspace"
if [ -n "$residue" ]; then
residue="these remain now: $residue"
else
residue="an unidentified cleanup step failed"
fi
if [ "$firewall_clean" = 0 ]; then
purge_report partial 1 0 "$module_removed" "$state_removed" "$external_removed" 1 \
"the module stays scheduled for removal at the next reboot, but $residue; the IPv6 ruleset could not be verified"
else
purge_report partial 1 1 "$module_removed" "$state_removed" "$external_removed" 1 \
"service and firewall are clean and the module stays scheduled for removal at the next reboot, but $residue"
fi
return 1
fi
if [ "$firewall_clean" = 0 ]; then
# "complete" is a contract, not a mood: it asserts a verified-clean
# firewall, which this run cannot. Everything else did succeed, so the
# honest receipt is a partial one naming the single unproven fact —
# and the return code still says the module is gone, because callers
# use it to decide whether APK-private data may now be cleared.
purge_report partial 1 0 1 1 1 1 "Zapret2 module data was permanently removed, but the IPv6 ruleset could not be verified; the pending reboot clears it"
return 0
fi
purge_report complete 1 1 1 1 1 1 "Zapret2 module data was permanently removed; APK preserved; reboot required"
}
clear_installed_apk_private_data() {
local package="com.zapret2.app" user
command -v am >/dev/null 2>&1 && command -v pm >/dev/null 2>&1 || return 1
user="$(am get-current-user 2>/dev/null)" || return 1
z2_purge_is_decimal "$user" || return 1
if ! pm path --user "$user" "$package" 2>/dev/null | grep -q '^package:'; then
# No installed APK means there is no APK-private state to retain or clear.
return 0
fi
pm clear --user "$user" "$package" >/dev/null 2>&1
}
manager_action() {
local token
Z2_PURGE_MACHINE=0
if z2_purge_request_is_live && [ "$Z2_PURGE_REQUEST_SOURCE" = manager ]; then
token="$Z2_PURGE_REQUEST_TOKEN"
echo "Second confirmation received. Permanently removing Zapret2 module data..."
if ! commit_purge manager "$token"; then
# The removal fence is durable from the moment it is published, so
# a failure here still leaves the module scheduled for removal.
# Saying only "failed" would leave the user expecting a module
# that the next boot deletes.
if module_removal_pending; then
echo "The module stays scheduled for removal and is deleted at the next boot; reinstall it afterwards if you want it back." >&2
fi
return 1
fi
if clear_installed_apk_private_data; then
echo "Zapret2 app data cleared; installed APK preserved. Reboot required."
else
# The module purge itself is complete; a missing/unclearable APK
# must not turn the committed action into a reported failure.
echo "WARNING: module cleanup completed, but APK-private data could not be cleared." >&2
fi
return
fi
remove_request_if_exact >/dev/null 2>&1 || true
prepare_purge manager || return 1
echo "WARNING: this permanently deletes the module, settings, lists, logs and recovery data."
echo "The Zapret2 APK is preserved. Press the module Action button again within 120 seconds to confirm."
}
case "${1:-}" in
--prepare)
[ "${3:-}" = --machine ] && Z2_PURGE_MACHINE=1
prepare_purge "${2:-}"
;;
--commit)
[ "${4:-}" = --machine ] && Z2_PURGE_MACHINE=1
commit_purge "${2:-}" "${3:-}"
;;
--manager-action) manager_action ;;
*)
echo "usage: $0 --prepare {app|manager|cli} --machine | --commit {app|manager|cli} TOKEN --machine | --manager-action" >&2
exit 2
;;
esac