magisk-zapret2/tests/shell/lifecycle-safety.sh
loop-uh 852d42c02e Answer in builtins what the replace path kept re-buying in forks
Android prices every fork at ~25ms and every exec at ~90ms, and the
preset apply was paying that price for questions it had already
answered: 95 separate stat calls re-proving file metadata one field at
a time, command substitutions around proc readers whose parsing was
already builtin-only, expected firewall rules rebuilt through captures
on every verify, and printf — an external binary on Android's mksh —
invoked once per line of every publication.

One stat capture now serves every metadata question a proof asks
(path_meta_capture, consumed by the uid/mode/nlink/size predicates and
retired on proof exit); the state-dir proof and a fully-pinned process
identity become lock-scoped facts on the same single-writer argument as
the owner read cache; proc_starttime/proc_argv0 gain fork-free
global-return forms with the printf wrappers kept as seams; the qnum
argument check reuses the cmdline snapshot argv0 already paid for;
expected firewall rules are built as data instead of captured; and
multi-line writers emit once through z2_emit_line, which resolves to
mksh's raw print builtin on the device and printf elsewhere. Load-time
probes replace the per-call command -v PATH walks, and log stamps reuse
one date exec per second via EPOCHREALTIME.

Measured on the Pixel 9 Pro XL: preset apply 56s before this series,
40s after the owner read cache, 14.6s now. tests/shell/run.sh passes;
the three test seams that named replaced internals (proc_starttime,
z2_fw_restore_command, the owner printf format) follow the new forms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 16:55:22 +03:00

330 lines
14 KiB
Shell

#!/bin/sh
set -eu
ROOT=$(CDPATH= cd -- "$(dirname "$0")/../.." && pwd)
TMP=${Z2_TEST_TMP:?}
CASE="$TMP/lifecycle-safety"
MOCK="$CASE/bin"
mkdir -p "$MOCK"
fail() { echo "FAIL: lifecycle-safety: $*" >&2; exit 1; }
cat > "$MOCK/iptables" <<'EOF'
#!/bin/sh
case "${Z2_QUERY_MODE:-clean}" in
fail) exit 42 ;;
esac
case " $* " in
*' -t mangle -L OUTPUT -n '*) exit 0 ;;
*' -t mangle -S ZAPRET2_OUT '*)
case "${Z2_QUERY_MODE:-clean}" in
present|foreign) echo '-N ZAPRET2_OUT'; exit 0 ;;
*) exit 1 ;;
esac
;;
*' -t mangle -S ZAPRET2_IN '*) exit 1 ;;
esac
case "${Z2_QUERY_MODE:-clean}: $* " in
present:*' -t mangle -C OUTPUT -j ZAPRET2_OUT '*) exit 0 ;;
present:*' -t mangle -S ')
printf '%s\n' '-N ZAPRET2_OUT' '-A OUTPUT -j ZAPRET2_OUT'
exit 0
;;
foreign:*' -t mangle -S ')
printf '%s\n' '-N ZAPRET2_OUT' '-A FORWARD -j ZAPRET2_OUT'
exit 0
;;
clean:*' -t mangle -S ') exit 0 ;;
*) exit 1 ;;
esac
EOF
chmod 0755 "$MOCK/iptables"
SCRIPT_DIR="$ROOT/zapret2/scripts"
ZAPRET_DIR="$ROOT/zapret2"
MODDIR="$ROOT"
STATE_DIR="$CASE/state"
mkdir -p "$STATE_DIR"
chmod 0700 "$STATE_DIR"
PATH="$MOCK:$PATH"
export PATH STATE_DIR
. "$ROOT/zapret2/scripts/common.sh"
# A stopped service must not perform the expensive exact identity proof for
# every Android PID. The shell-builtin cmdline prefilter admits the current
# process only when its actual argv0 prefix is selected.
(
CURRENT_ARGV0="$(proc_argv0 "$$")" || fail "current argv0 unavailable"
NFQWS2="$CURRENT_ARGV0"
proc_cmdline_may_match_nfqws "$$" || fail "exact argv0 candidate was filtered out"
NFQWS2="${CURRENT_ARGV0}.not-the-current-process"
if proc_cmdline_may_match_nfqws "$$"; then fail "non-candidate argv0 prefix was admitted"; fi
NFQWS2=/definitely/not/a/zapret2/process
verify_nfqws_pid() { fail "strict PID proof ran for a non-candidate process"; }
scan_exact_owned_nfqws >/dev/null || fail "empty exact process scan failed"
[ -z "$OWNED_SCAN_PIDS" ] || fail "empty exact process scan reported an owner"
)
Z2_QUERY_MODE=fail; export Z2_QUERY_MODE
set +e
owned_family_present iptables
rc=$?
set -e
[ "$rc" = 2 ] || fail "query failure was not tri-state error"
if owned_family_absent iptables; then fail "query failure was accepted as absence"; fi
Z2_QUERY_MODE=clean; export Z2_QUERY_MODE
set +e
owned_family_present iptables
rc=$?
set -e
[ "$rc" = 1 ] || fail "clean snapshot was not absence"
owned_family_absent iptables || fail "clean snapshot absence was rejected"
Z2_QUERY_MODE=present; export Z2_QUERY_MODE
owned_family_present iptables || fail "owned chain/anchor was not detected"
if owned_family_absent iptables; then fail "owned state was accepted as absent"; fi
# An IPv6 frontend that exists but cannot answer is not a proof of absence.
# A family that cannot be queried now but might answer in a moment is a busy
# lock: teardown must retry rather than accept it. A family that stays
# unqueryable can never be proven on this device, so refusing forever would
# fence every teardown until a reboot that would refuse the same way — it is
# skipped instead, and the skip is reported unless our own record already
# proves this generation published nothing there.
cat > "$MOCK/ip6tables" <<'EOF'
#!/bin/sh
count_file="${Z2_IP6_PROBE_COUNT:-}"
if [ -n "$count_file" ]; then
n=0
[ ! -f "$count_file" ] || IFS= read -r n < "$count_file"
n=$((n + 1))
printf '%s\n' "$n" > "$count_file"
succeed_at="${Z2_IP6_PROBE_SUCCEED_AT:-0}"
if [ "$succeed_at" -gt 0 ] && [ "$n" -ge "$succeed_at" ]; then exit 0; fi
fi
exit 42
EOF
chmod 0755 "$MOCK/ip6tables"
(
Z2_QUERY_MODE=clean; export Z2_QUERY_MODE
z2_fw_cleanup_family() { return 0; }
FIREWALL_PROBE_ATTEMPTS=3
Z2_IP6_PROBE_COUNT="$CASE/ip6probe"; export Z2_IP6_PROBE_COUNT
Z2_IP6_PROBE_SUCCEED_AT=2; export Z2_IP6_PROBE_SUCCEED_AT
rm -f "$Z2_IP6_PROBE_COUNT"
CLEANUP_IPV6_OWNERSHIP_EXPECTED=1
cleanup_owned_firewall audited ||
fail "a frontend that answered on retry was treated as permanently unavailable"
[ "${FIREWALL_IPV6_SKIPPED_UNPROVEN:-0}" = 0 ] ||
fail "a family that was actually torn down was reported as skipped"
Z2_IP6_PROBE_SUCCEED_AT=0; export Z2_IP6_PROBE_SUCCEED_AT
rm -f "$Z2_IP6_PROBE_COUNT"
FIREWALL_PROBE_ATTEMPTS=1
CLEANUP_IPV6_OWNERSHIP_EXPECTED=1
cleanup_owned_firewall audited ||
fail "a permanently unqueryable family fenced the teardown"
[ "${FIREWALL_IPV6_SKIPPED_UNPROVEN:-0}" = 1 ] ||
fail "an unqueryable IPv6 family was skipped without reporting it"
rm -f "$Z2_IP6_PROBE_COUNT"
CLEANUP_IPV6_OWNERSHIP_EXPECTED=0
cleanup_owned_firewall audited ||
fail "a family this generation never published still blocked cleanup"
[ "${FIREWALL_IPV6_SKIPPED_UNPROVEN:-0}" = 0 ] ||
fail "a proven-absent family produced a reservation"
)
# The probe budget belongs to the preflight, which is also the only step that
# can capture a family's baseline. A teardown that waited again on its own
# would walk a recovered frontend into an audited cleanup with no baseline —
# and fail the teardown precisely on the devices the budget was added for.
(
Z2_QUERY_MODE=clean; export Z2_QUERY_MODE
Z2_IP6_PROBE_COUNT="$CASE/ip6probe.preflight"; export Z2_IP6_PROBE_COUNT
Z2_IP6_PROBE_SUCCEED_AT=0; export Z2_IP6_PROBE_SUCCEED_AT
rm -f "$Z2_IP6_PROBE_COUNT"
FIREWALL_PROBE_ATTEMPTS=1
audit_owned_firewall_for_cleanup ||
fail "an unqueryable IPv6 family blocked the cleanup preflight"
[ "${FIREWALL_IPV6_UNQUERYABLE:-0}" = 1 ] ||
fail "the preflight did not record that it could not read the family"
# The frontend comes back before teardown. Cleanup must not reach into an
# audited teardown for a family whose baseline was never captured.
Z2_IP6_PROBE_SUCCEED_AT=1; export Z2_IP6_PROBE_SUCCEED_AT
rm -f "$Z2_IP6_PROBE_COUNT"
z2_fw_cleanup_family() {
[ "$1" != ip6tables ] ||
fail "audited teardown ran for a family whose baseline was never captured"
return 0
}
CLEANUP_IPV6_OWNERSHIP_EXPECTED=1
cleanup_owned_firewall audited ||
fail "teardown failed on a family whose baseline the preflight never captured"
[ "${FIREWALL_IPV6_SKIPPED_UNPROVEN:-0}" = 1 ] ||
fail "the skipped family was not reported"
)
# A frontend that answers now and goes busy later. In clean mode it delegates
# to the IPv4 mock, so the preflight sees a real, readable, empty family.
cat > "$MOCK/ip6tables" <<'EOF'
#!/bin/sh
mode=fail
[ ! -f "$Z2_IP6_MODE_FILE" ] || IFS= read -r mode < "$Z2_IP6_MODE_FILE"
[ "$mode" != fail ] || exit 42
Z2_QUERY_MODE="$mode" exec iptables "$@"
EOF
chmod 0755 "$MOCK/ip6tables"
# A preflight read that found nothing of ours is positive knowledge. If the
# frontend goes busy before teardown, discarding it turns "we looked and it was
# empty" into "we could not look" and raises a reservation the run can prove is
# unnecessary — a false alarm on the one channel that is meant to carry real
# uncertainty to the user.
(
Z2_QUERY_MODE=clean; export Z2_QUERY_MODE
Z2_IP6_MODE_FILE="$CASE/ip6mode"; export Z2_IP6_MODE_FILE
z2_fw_cleanup_family() { return 0; }
printf 'clean\n' > "$Z2_IP6_MODE_FILE"
FIREWALL_PROBE_ATTEMPTS=1
audit_owned_firewall_for_cleanup || fail "a readable empty IPv6 family failed the preflight"
[ "${FIREWALL_IPV6_UNQUERYABLE:-1}" = 0 ] ||
fail "a family the preflight actually read was recorded as unreadable"
[ "${FIREWALL_IPV6_AUDITED_EMPTY:-0}" = 1 ] ||
fail "the preflight did not record that it proved the family empty"
printf 'fail\n' > "$Z2_IP6_MODE_FILE"
CLEANUP_IPV6_OWNERSHIP_EXPECTED=1
cleanup_owned_firewall audited ||
fail "a family proven empty by the preflight fenced the teardown"
[ "${FIREWALL_IPV6_SKIPPED_UNPROVEN:-0}" = 0 ] ||
fail "a family the preflight proved empty still produced a reservation"
# The same window, but the preflight found owned rules. Losing the frontend
# now is real uncertainty and must be reported.
printf 'present\n' > "$Z2_IP6_MODE_FILE"
audit_owned_firewall_for_cleanup || fail "a readable populated IPv6 family failed the preflight"
[ "${FIREWALL_IPV6_AUDITED_EMPTY:-1}" = 0 ] ||
fail "a family holding owned rules was recorded as proven empty"
printf 'fail\n' > "$Z2_IP6_MODE_FILE"
cleanup_owned_firewall audited ||
fail "an unreadable populated family fenced the teardown"
[ "${FIREWALL_IPV6_SKIPPED_UNPROVEN:-0}" = 1 ] ||
fail "a family that held owned rules was skipped without reporting it"
)
# An owned teardown captures its own baseline, so it never inherits the
# preflight's proof — including a stale one left by an earlier audited run in
# the same process.
(
Z2_QUERY_MODE=clean; export Z2_QUERY_MODE
Z2_IP6_MODE_FILE="$CASE/ip6mode.owned"; export Z2_IP6_MODE_FILE
FIREWALL_PROBE_ATTEMPTS=1
printf 'clean\n' > "$Z2_IP6_MODE_FILE"
ipv6_torn_down=0
z2_fw_cleanup_family() { [ "$1" != ip6tables ] || ipv6_torn_down=1; return 0; }
CLEANUP_IPV6_OWNERSHIP_EXPECTED=1
cleanup_owned_firewall owned || fail "an owned teardown failed on a readable family"
[ "$ipv6_torn_down" = 1 ] ||
fail "an owned teardown skipped a family it could read and own a baseline for"
[ "${FIREWALL_IPV6_SKIPPED_UNPROVEN:-0}" = 0 ] ||
fail "a family the owned teardown actually removed was reported as skipped"
printf 'fail\n' > "$Z2_IP6_MODE_FILE"
FIREWALL_IPV6_AUDITED_EMPTY=1
z2_fw_cleanup_family() {
[ "$1" != ip6tables ] || fail "an owned teardown ran against an unreachable frontend"
return 0
}
cleanup_owned_firewall owned || fail "an unreadable family fenced an owned teardown"
[ "${FIREWALL_IPV6_SKIPPED_UNPROVEN:-0}" = 1 ] ||
fail "an owned teardown reused another mode's proof to suppress a reservation"
)
# The checks that run after a teardown re-ask the kernel. A frontend that goes
# busy in that window would erase the teardown's own evidence, so the teardown
# records that it captured the family and committed its removal — and stops
# recording it the moment it did not.
(
Z2_QUERY_MODE=clean; export Z2_QUERY_MODE
Z2_IP6_MODE_FILE="$CASE/ip6mode.proven"; export Z2_IP6_MODE_FILE
FIREWALL_PROBE_ATTEMPTS=1
printf 'clean\n' > "$Z2_IP6_MODE_FILE"
z2_fw_cleanup_family() { return 0; }
CLEANUP_IPV6_OWNERSHIP_EXPECTED=1
cleanup_owned_firewall owned || fail "an owned teardown failed on a readable family"
[ "${FIREWALL_IPV6_TEARDOWN_PROVEN:-0}" = 1 ] ||
fail "a teardown that removed the family did not record its own proof"
printf 'fail\n' > "$Z2_IP6_MODE_FILE"
cleanup_owned_firewall owned || fail "an unreadable family fenced an owned teardown"
[ "${FIREWALL_IPV6_TEARDOWN_PROVEN:-0}" = 0 ] ||
fail "a teardown that never reached the family still claimed to have removed it"
printf 'clean\n' > "$Z2_IP6_MODE_FILE"
z2_fw_cleanup_family() { [ "$1" != ip6tables ]; }
if cleanup_owned_firewall owned; then
fail "a failed IPv6 teardown was reported as successful"
fi
[ "${FIREWALL_IPV6_TEARDOWN_PROVEN:-0}" = 0 ] ||
fail "a teardown that failed to remove the family claimed proof anyway"
)
rm -f "$MOCK/ip6tables"
Z2_QUERY_MODE=foreign; export Z2_QUERY_MODE
if z2_fw_cleanup_is_unambiguous iptables; then
fail "foreign reference to the stable namespace passed cleanup preflight"
fi
# An operation's own receipt must carry that operation's reservation. A stopped
# receipt that always certified the ruleset forced the caller to withhold the
# receipt and infer the reservation from a second, separately-raced observation
# — the extra trip through the snapshot that kept losing it.
(
ZAPRET2_EMIT_STATUS_V6=1; export ZAPRET2_EMIT_STATUS_V6
STATUS_RULESET_VERIFIED=0
receipt="$(emit_committed_status_v6 stopped idle none)" ||
fail "a stop with a reservation could not emit its receipt at all"
printf '%s\n' "$receipt" | grep -Fxq 'Z2_STATUS=stopped' ||
fail "the reserved receipt did not report a stopped service"
printf '%s\n' "$receipt" | grep -Fxq 'Z2_RULESET_VERIFIED=0' ||
fail "a stopped receipt certified a ruleset this teardown could not read"
STATUS_RULESET_VERIFIED=1
receipt="$(emit_committed_status_v6 stopped idle none)" ||
fail "a fully verified stop could not emit its receipt"
printf '%s\n' "$receipt" | grep -Fxq 'Z2_RULESET_VERIFIED=1' ||
fail "a fully verified stop lost its verification claim"
unset STATUS_RULESET_VERIFIED
receipt="$(emit_committed_status_v6 stopped idle none)" ||
fail "an unrecorded verification could not emit a receipt"
printf '%s\n' "$receipt" | grep -Fxq 'Z2_RULESET_VERIFIED=0' ||
fail "an unrecorded verification defaulted to asserting one"
)
if sed -n '/if \[ "\$STOP_STATUS_COMMITTED" = 1 \]/p' "$ROOT/zapret2/scripts/zapret-stop.sh" |
grep -Fq 'FIREWALL_IPV6_SKIPPED_UNPROVEN'; then
fail "the stop receipt is withheld again when a family had to be skipped"
fi
grep -Fq 'boot_id=$boot_id' "$ROOT/zapret2/scripts/common.sh" || fail "owner publication is not boot-bound"
grep -Fq 'return 2' "$ROOT/zapret2/scripts/common.sh" || fail "tri-state query error is absent"
grep -Fq 'phase_at_least process-clean' "$ROOT/zapret2/scripts/zapret-full-rollback.sh" || fail "rollback resume gates are absent"
# Boot-local firewall state is reconstructed from one stable namespace. It has
# no durability journal and rejects ambiguous foreign references before delete.
grep -Fq 'z2_fw_cleanup_is_unambiguous' "$ROOT/zapret2/scripts/firewall-reconciler.sh" ||
fail "stable namespace cleanup preflight is absent"
grep -Fq -- '--test --noflush' "$ROOT/zapret2/scripts/firewall-reconciler.sh" ||
fail "whole-batch restore validation is absent"
if grep -Eq 'prepare_teardown_marker|consume_bracketed_teardown_target|target-consumed' \
"$ROOT/zapret2/scripts/firewall-reconciler.sh"; then
fail "firewall reconciler contains obsolete teardown WAL machinery"
fi
echo "Lifecycle safety shell tests passed"