Files

3054 lines
141 KiB
Bash
Executable File

#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
#
# Architecture Guard.
#
# Static, cross-file invariants that the behavioural suites cannot see because
# each of them only exercises one layer at a time. Every guard here exists
# because a real defect slipped through the whole suite:
#
# guard 1 the generators read the UCI configuration through
# HP_DIR + '/config', i.e. /etc/homeproxy-pro/config/homeproxy-pro - a path
# that exists nowhere. uci.load() returned null, every uci.get()
# returned null, and the Loader silently produced pure defaults, so
# every generated config lacked the main-out, the route/dns finals
# and all of the user's ports and nodes. The staged tests passed
# because the test rewrote HP_DIR and staged the fixture wherever
# the code happened to look.
#
# guard 2 update_subscriptions.uc read subscription_urls and filter_keywords
# off access_control.subscription, but the Loader puts them on
# access_control itself. Both were always [], so the updater's
# `if (!isEmpty(subscription_urls))` guard never called main(): the
# LuCI button and the cron entry were silent no-ops that exited 0.
# The project's own model test asserted the *correct* shape, which
# is exactly why the consumer's wrong read was never questioned.
#
# The lesson both share: a test that stages its own inputs cannot notice that
# production reads a different place. These guards compare the layers to each
# other instead of comparing each layer to a fixture.
#
# POSIX sh + python3 for the scanners python is better at. No ucode,
# no node. Run:
# sh tests/arch-guard.sh [repo-root]
set -u
ROOT="$(cd "${1:-$(dirname "$0")/..}" && pwd)"
SCRIPTS="$ROOT/root/etc/homeproxy-pro/scripts"
RPC="$ROOT/root/usr/share/rpcd/ucode/luci.homeproxy-pro"
ACL="$ROOT/root/usr/share/rpcd/acl.d/luci-app-homeproxy-pro.json"
VIEWS="$ROOT/htdocs/luci-static/resources"
RUNTIME="$SCRIPTS/runtime"
# assert_empty treats "no stdout" as a pass, which makes the suite
# green on an unrelated /etc tree or any path that exists but is not
# this repo. Bail out loudly before any of that happens: a guard
# that says PASS while scanning nothing is worse than a guard that
# says FAIL, because nothing else in the report can tell them apart.
if [ ! -d "$SCRIPTS" ] || [ ! -f "$RPC" ] || [ ! -f "$ACL" ] || [ ! -d "$VIEWS" ]; then
printf 'FATAL: arch-guard.sh needs the full repo tree under %s\n' "$ROOT" >&2
printf ' (missing %s%s%s%s)\n' \
"$([ -d "$SCRIPTS" ] || printf '%s ' "$SCRIPTS")" \
"$([ -f "$RPC" ] || printf '%s ' "$RPC")" \
"$([ -f "$ACL" ] || printf '%s ' "$ACL")" \
"$([ -d "$VIEWS" ] || printf '%s ' "$VIEWS")" >&2
exit 2
fi
FAILED=0
checks=0
pass() { checks=$((checks + 1)); printf 'PASS: %s\n' "$1"; }
fail() { checks=$((checks + 1)); FAILED=1; printf 'FAIL: %s\n' "$1"; }
# assert_empty <description> <command...>
#
# Empty stdout is a pass only when the scan actually ran. "Actually ran"
# means: the command existed (rc != 127) and could read its target (rc
# != 126, "cannot execute"). A grep that finds nothing exits 1 with
# empty stdout - the legitimate "nothing to report" answer - and stays
# a pass. A grep on a path that does not exist exits 2 ("No such
# file"), which we DO want to flag: the previous behaviour silently
# produced green checks for nonexistent repos.
assert_empty() {
desc="$1"; shift
out="$("$@" 2>/dev/null)"
rc=$?
if [ "$rc" -ge 126 ]; then
fail "$desc (scanner exited $rc - the command or the target is missing)"
return
fi
if [ -z "$out" ]; then
pass "$desc"
else
fail "$desc"
printf ' %s\n' "$out" | head -20
fi
}
# assert_nonempty <description> <command...>
assert_nonempty() {
desc="$1"; shift
out="$("$@" 2>/dev/null)"
if [ -n "$out" ]; then
pass "$desc"
else
fail "$desc (nothing found)"
fi
}
echo "== guard 1: the generators read the production UCI directory =="
# The confdir the Loader hands to cursor() must be /etc/config. Anything else
# means uci.load() reads a file the package does not ship.
if grep -q "^export const UCICONFIG_DIR = '/etc/config';$" "$SCRIPTS/homeproxy-pro.uc"; then
pass "UCICONFIG_DIR defaults to /etc/config"
else
fail "UCICONFIG_DIR does not default to /etc/config"
grep -n "UCICONFIG_DIR" "$SCRIPTS/homeproxy-pro.uc" | sed 's/^/ /'
fi
for g in generate_client.uc generate_server.uc; do
if grep -q "Loader.load(UCICONFIG_DIR)" "$SCRIPTS/$g"; then
pass "$g loads through UCICONFIG_DIR"
else
fail "$g does not call Loader.load(UCICONFIG_DIR)"
grep -n "Loader.load" "$SCRIPTS/$g" | sed 's/^/ /'
fi
done
# The original mistake, in either spelling: deriving the UCI directory from
# HP_DIR. HP_DIR is /etc/homeproxy-pro and holds resources/scripts, not config.
assert_empty "no source derives the UCI dir from HP_DIR" \
grep -rn "Loader.load(HP_DIR\|Loader\.load( *HP_DIR" "$SCRIPTS"
# Every Loader.load() call site must use one of exactly two forms: the shared
# constant, or the bare default cursor(). A literal path - or any other
# expression - is how the original mistake would come back.
#
# Coverage boundary: comments are removed and all whitespace is collapsed
# before matching, so a call split over several lines
# (`Loader.load(` / newline / `UCICONFIG_DIR)`) is still one call site, and a
# call mentioned only inside a comment is not a call site at all. A call the
# code assembles through a variable (`const f = Loader.load; f(...)`) is out
# of scope for a static guard; that boundary is stated, not silently assumed.
BADLOAD="$(python3 - "$SCRIPTS" <<'PY'
import pathlib, re, sys
# Blank out comments only; string literals are kept, because the argument of a
# Loader.load() call has to stay visible. SQ and DQ exist because an escaped
# quote inside this here-document upsets the shell command-substitution
# scanner and truncates the program, which would leave the check passing
# without testing anything.
SQ, DQ = "'", '"'
STRING = DQ + r'(?:\\.|[^"\\])*' + DQ + '|' + SQ + r"(?:\\.|[^'\\])*" + SQ
COMMENT = r'/\*.*?\*/|//[^\n]*'
TOKENS = re.compile(STRING + '|' + COMMENT, re.S)
def blank(match):
return ' ' if match.group(0).startswith('/') else match.group(0)
bad = set()
for f in sorted(pathlib.Path(sys.argv[1]).rglob('*.uc')):
src = TOKENS.sub(blank, f.read_text(encoding='utf-8', errors='replace'))
flat = re.sub(r'\s+', ' ', src)
for arg in re.findall(r'Loader\s*\.\s*load\s*\(([^()]*)\)', flat):
arg = arg.strip()
if arg not in ('', 'UCICONFIG_DIR'):
bad.add('%s: Loader.load(%s)' % (f.relative_to(sys.argv[1]), arg))
print('\n'.join(sorted(bad)))
PY
)" || BADLOAD="__SCAN_FAILED__"
if [ "$BADLOAD" = "__SCAN_FAILED__" ]; then
fail "the Loader.load() scan could not run - fix the guard before trusting a pass"
elif [ -z "$BADLOAD" ]; then
pass "every Loader.load() call site uses UCICONFIG_DIR or the bare default"
else
fail "unexpected Loader.load() argument:"
printf ' %s\n' "$BADLOAD"
fi
echo
echo "== guard 2: the subscription updater reads fields where the Loader puts them =="
# Keys the Loader nests inside access_control.subscription (the
# load_settings(...) list).
LOADER="$SCRIPTS/config/loader.uc"
NESTED="$(awk '
/subscription: load_settings\(uci, SECTION\.subscription, \[/ { inb = 1; next }
inb && /\]\)/ { inb = 0; next }
inb { print }
' "$LOADER" | grep -oE "'[a-z_][a-z0-9_]*'" | tr -d "'" | sort -u)"
if [ -n "$NESTED" ]; then
pass "parsed the nested subscription keys ($(printf '%s' "$NESTED" | wc -l | tr -d ' ') of them)"
else
fail "could not parse the nested subscription keys out of loader.uc"
fi
# Every `sub.<field>` read in the updater must be one of them. `sub` is bound
# to loaded.access_control.subscription.
UPDATER="$SCRIPTS/update_subscriptions.uc"
BAD=""
for f in $(grep -oE '\bsub\.[a-z_][a-z0-9_]*' "$UPDATER" | sed 's/^sub\.//' | sort -u); do
if ! printf '%s\n' "$NESTED" | grep -qx "$f"; then
BAD="$BAD sub.$f"
fi
done
if [ -z "$BAD" ]; then
pass "every sub.<field> read in update_subscriptions.uc is nested under subscription"
else
fail "update_subscriptions.uc reads fields off subscription that the Loader does not nest there:$BAD"
fi
# And the other direction: the two fields that regressed sit on access_control
# itself, so the updater must reach them through access_control - reading them
# through `sub.` is what made them a permanent []. Asserted positively too, so
# the check cannot pass by finding nothing (the first draft of this guard
# grepped the *loader* for an "access_control.X" spelling that never appears
# there, matched nothing, and silently checked nothing at all).
for f in subscription_urls filter_keywords; do
if grep -qE "\bsub\.$f\b" "$UPDATER"; then
fail "update_subscriptions.uc reads sub.$f, but load_access_control() puts it on access_control"
elif grep -qE "\baccess_control\.$f\b" "$UPDATER"; then
pass "$f is read from access_control"
else
fail "$f is neither read from access_control nor from sub - the updater lost it entirely"
fi
done
echo
echo "== guard 3: the ACL grants only what the browser actually writes =="
# The certificate buttons and the staging path each one uploads to. The
# frontend derives /tmp/homeproxy_cert_<name>.tmp from the same name it passes
# to certificate_write, so the button list is the source of truth for what the
# ACL needs.
BTN="$(grep -rhoE "uploadCertificate[^;]*'[a-z_]+'\)" "$VIEWS" \
| grep -oE "'[a-z_]+'\)$" | tr -d "')" | sort -u)"
if [ -n "$BTN" ]; then
pass "found the certificate upload buttons: $(printf '%s ' $BTN)"
else
fail "could not find any uploadCertificate call site under $VIEWS"
fi
for n in $BTN; do
if grep -q "/tmp/homeproxy_cert_$n.tmp" "$ACL"; then
pass "the ACL grants the staging path for $n"
else
fail "the ACL is missing the staging path for $n"
fi
done
# The write-file block, as a list of paths.
WRITE_FILES="$(awk '
/"write"[[:space:]]*:/ { inwrite = 1 }
inwrite && /"file"[[:space:]]*:/ { infile = 1; next }
infile && /^[[:space:]]*}/ { infile = 0; inwrite = 0; next }
infile { print }
' "$ACL" | grep -oE '"/[^"]*"' | tr -d '"')"
if [ -n "$WRITE_FILES" ]; then
pass "parsed the ACL write-file list ($(printf '%s\n' "$WRITE_FILES" | grep -c . ) paths)"
else
fail "could not parse the ACL write-file list"
fi
# The file ACL governs what a *browser session* may touch through fs.*. The
# backend writes certs/ and resources/ as root, authorised by its ubus method
# entry - not by this list. So a /etc/ entry here grants nothing the feature
# needs, and does grant a session holding only this ACL the ability to
# overwrite server_privatekey.pem directly through fs.write, bypassing the PEM
# and binary checks in certificate_write.
ETC_WRITES="$(printf '%s\n' "$WRITE_FILES" | grep '^/etc/' || true)"
if [ -z "$ETC_WRITES" ]; then
pass "the ACL write list grants no /etc/ path"
else
fail "the ACL write list grants /etc/ paths no browser operation uses:"
printf ' %s\n' "$ETC_WRITES"
fi
# The general form, so a future fs.write has to come with a deliberate ACL
# change rather than silently inheriting a stale grant.
assert_empty "the frontend makes no fs.write call" \
grep -rn "fs\.write" "$VIEWS"
echo
echo "== guard 4: the ACL and the rpcd method table agree =="
BACKEND_METHODS="$(sed -n 's/^\t\([a-z_][a-z0-9_]*\): {$/\1/p' "$RPC" | sort -u)"
if [ -n "$BACKEND_METHODS" ]; then
pass "parsed $(printf '%s\n' "$BACKEND_METHODS" | grep -c .) rpcd methods"
else
fail "could not parse any method out of $(basename "$RPC")"
fi
ACL_METHODS="$(awk '
/"ubus"[[:space:]]*:/ { inubus = 1; next }
inubus && /^[[:space:]]*}/ { inubus = 0; next }
inubus && /"luci.homeproxy-pro"[[:space:]]*:/ { inlist = 1; next }
inlist && /\]/ { inlist = 0; next }
inlist { print }
' "$ACL" | grep -oE '"[a-z_]+"' | tr -d '"' | sort -u)"
if [ -n "$ACL_METHODS" ]; then
pass "parsed $(printf '%s\n' "$ACL_METHODS" | grep -c .) ACL ubus methods"
else
fail "could not parse any ubus method out of the ACL"
fi
# Not `comm`: it takes file arguments, and POSIX sh has no process
# substitution. The first draft used it anyway, so comm failed, the variable
# came back empty and the check passed without testing anything.
UNGATED=""
for m in $BACKEND_METHODS; do
printf '%s\n' "$ACL_METHODS" | grep -qx "$m" || UNGATED="$UNGATED $m"
done
if [ -z "$UNGATED" ]; then
pass "every rpcd method is reachable through the ACL"
else
fail "these rpcd methods have no ACL entry, so no session can call them:"
printf ' %s\n' "$UNGATED"
fi
PHANTOM=""
for m in $ACL_METHODS; do
printf '%s\n' "$BACKEND_METHODS" | grep -qx "$m" || PHANTOM="$PHANTOM $m"
done
if [ -z "$PHANTOM" ]; then
pass "the ACL grants no method the module does not define"
else
fail "the ACL grants methods that do not exist:"
printf ' %s\n' "$PHANTOM"
fi
# A wildcard would make the two checks above meaningless.
if grep -qE '"[*]"' "$ACL"; then
fail "the ACL contains a wildcard"
else
pass "the ACL contains no wildcard"
fi
echo
echo "== guard 5: every RPC the frontend calls is a real method, and is tested =="
FRONTEND_METHODS="$(grep -rhoE "rpcCall\('[a-z_]+'" "$VIEWS" \
| sed "s/rpcCall('//" | tr -d "'" | sort -u)"
if [ -n "$FRONTEND_METHODS" ]; then
pass "parsed $(printf '%s\n' "$FRONTEND_METHODS" | grep -c .) rpcCall method names"
else
fail "found no rpcCall method names under $VIEWS"
fi
UNKNOWN=""
for m in $FRONTEND_METHODS; do
# 'list' is the ubus *service* object, not this module's.
if [ "$m" = "list" ]; then
grep -rq "object: 'service'" "$VIEWS" \
|| UNKNOWN="$UNKNOWN list(not via the service object)"
continue
fi
printf '%s\n' "$BACKEND_METHODS" | grep -qx "$m" || UNKNOWN="$UNKNOWN $m"
done
if [ -z "$UNKNOWN" ]; then
pass "every frontend rpcCall names a method the backend defines"
else
fail "the frontend calls methods the backend does not define:$UNKNOWN"
fi
# And every one of them must be exercised by a test *script*, so a method
# cannot be shipped - or a call site broken - without a test noticing. The
# old check was `grep -rq "$m" "$ROOT/tests"`: it counted the method name
# appearing anywhere under tests/, including tests/README.md prose, so a
# method could lose its only test and the guard would still pass on a
# sentence. A script qualifies only when it both names the method and
# dispatches through the RPC layer (`.call(...)` - e.g. the
# `rpc[method].call({...})` sweep in test_rpc_methods.sh - or a frontend
# `rpcCall(...)` driven from a JS harness). arch-guard.sh itself is excluded:
# it names every method while parsing the frontend, which is not exercising
# them.
UNTESTED="$(python3 - "$ROOT" $FRONTEND_METHODS <<'PY'
import pathlib, re, sys
root = pathlib.Path(sys.argv[1]) / 'tests'
methods = sys.argv[2:]
files = [p for p in root.rglob('*')
if p.suffix in ('.sh', '.uc') and p.name != 'arch-guard.sh']
bodies = [p.read_text(encoding='utf-8', errors='replace') for p in files]
dispatch = re.compile(r'\.call\s*\(|rpcCall\s*\(')
bad = []
for m in methods:
if m == 'list':
continue
name = re.compile(r'\b%s\b' % re.escape(m))
if not any(name.search(b) and dispatch.search(b) for b in bodies):
bad.append(m)
print(' '.join(bad))
PY
)" || UNTESTED="__SCAN_FAILED__"
if [ "$UNTESTED" = "__SCAN_FAILED__" ]; then
fail "the test-script scan could not run - fix the guard before trusting a pass"
elif [ -z "$UNTESTED" ]; then
pass "every RPC the frontend calls is exercised by a test script"
else
fail "these RPCs are called by the frontend but exercised by no test script:$UNTESTED"
fi
echo
echo "== guard 6: every certificate button has a backend case =="
# guard 3 already found $BTN from the views.
BACKEND_CASES="$(awk '/certificate_write: \{/,/^\t\},/' "$RPC" \
| grep -oE "case '[a-z_]+'" | sed "s/case '//" | tr -d "'" | sort -u)"
if [ -n "$BACKEND_CASES" ]; then
pass "parsed the certificate_write cases: $(printf '%s ' $BACKEND_CASES)"
else
fail "could not parse the certificate_write cases"
fi
for n in $BTN; do
printf '%s\n' "$BACKEND_CASES" | grep -qx "$n" \
&& pass "certificate_write handles '$n'" \
|| fail "the '$n' upload button has no certificate_write case"
done
echo
echo "== guard 7: the semantic layers do not touch UCI =="
# UCI -> Loader -> {Parser, Generator, Runtime}: config/loader.uc owns the only
# cursor on the client path. A parser or generator that grows its own cursor
# breaks the layering this refactor exists to establish.
LAYERS="$SCRIPTS/generator $SCRIPTS/parser $SCRIPTS/config/model.uc $SCRIPTS/config/adapter.uc"
assert_empty "no parser/generator/model/adapter imports the uci module" \
grep -rn "from 'uci'" $LAYERS
assert_empty "no parser/generator/model/adapter opens a cursor" \
grep -rn "cursor(" $LAYERS
assert_nonempty "the Loader is still the one that owns the cursor" \
grep -rn "cursor(" "$SCRIPTS/config/loader.uc"
echo
echo "== guard 8: the generators write through a private scratch dir =="
# reload_service generates the client and start_service generates it again, so
# two runs can overlap. A fixed `<out>.tmp` name in RUN_DIR meant both wrote the
# same file, and `sing-box check` could validate a file the other run was still
# writing - the winner then installed a half-written config. mkdtemp() gives each
# run its own 0700 directory.
for g in generate_client.uc generate_server.uc; do
if grep -q "mkdtemp()" "$SCRIPTS/$g"; then
pass "$g uses mkdtemp() for its scratch dir"
else
fail "$g does not use mkdtemp() - its scratch path is shared between runs"
fi
# The specific shape that was wrong: a temp path built from RUN_DIR.
SHARED="$(grep -n "RUN_DIR + '/sing-box-.*\.tmp'" "$SCRIPTS/$g" || true)"
if [ -z "$SHARED" ]; then
pass "$g does not build a fixed temp path under RUN_DIR"
else
fail "$g builds a shared temp path under RUN_DIR:"
printf ' %s\n' "$SHARED"
fi
done
echo
echo "== guard 9: the pgrep fallback still matches the procd command =="
# hp_instance_running falls back to `pgrep -f` only when ubus cannot be asked at
# all, and the pattern it matches has to stay in step with the command procd is
# told to run. Change either alone and the fallback silently stops matching -
# and then the health gate degrades to "always times out", which rolls back a
# perfectly good configuration. A wrong pattern would look like a bad config.
# ^[^#]* keeps this to code: health.sh's header comment mentions `pgrep -f`
# while explaining why the procd answer wins, and the first version of this
# guard matched that instead of the pattern.
PGREP_LINE="$(grep -nE '^[^#]*pgrep -f' "$RUNTIME/health.sh" | head -1)"
PROCD_LINE="$(grep -nE '^[^#]*procd_append_param command run' "$RUNTIME/service.sh" | head -1)"
if printf '%s' "$PGREP_LINE" | grep -q 'pgrep -f "run --config '; then
pass "the pgrep fallback matches 'run --config <config>'"
else
fail "the pgrep fallback no longer matches 'run --config <config>':"
printf ' %s\n' "$PGREP_LINE"
fi
if printf '%s' "$PROCD_LINE" | grep -q 'procd_append_param command run --config '; then
pass "procd is told to run 'run --config <config>'"
else
fail "the procd command no longer starts with 'run --config':"
printf ' %s\n' "$PROCD_LINE"
fi
echo
echo "== guard 10: nothing installs or removes packages on a target =="
# On 2026-09-15 an `apk add luci-app-homeproxy-pro` on the test machine rewrote
# /etc/config/homeproxy-pro from the feed package and destroyed the node
# configuration - six nodes plus the dns, server and subscription sections, with
# no backup and no snapshot. The suite stages instead, and this is the guard
# that keeps it that way: a package-manager *write* is never part of testing.
#
# Reading is fine and is used: tests/run.sh reports the target's installed
# version with `apk list -I`, and the opkg fallback reads `opkg status`.
# grep -v drops comment lines: this guard's own explanation quotes the command
# it forbids, and the first version flagged itself.
MUTATING="$(grep -rnE '(^|[^a-z-])(apk|opkg)[[:space:]]+(add|install|del|delete|remove|upgrade|fix|update)([[:space:]]|$)' \
"$ROOT/.github/workflows" "$ROOT/tests" 2>/dev/null \
| grep -vE ':[0-9]+:[[:space:]]*#' || true)"
if [ -z "$MUTATING" ]; then
pass "no workflow or test runs a package-manager write"
else
fail "a package-manager write appears - this is how the device config was lost:"
printf ' %s\n' "$MUTATING"
fi
echo
echo "== guard 11: firewall_post.ut validates every UCI-derived field =="
# Three code-review findings rolled into one guard, because they share the
# same shape: any UCI value the template concatenates into an nft expression
# is an injection surface (closes a set expression, the whole fw4 reload
# fails, the router loses its firewall). The defensive helpers exist; the
# guard exists so they cannot quietly stop being used.
TEMPLATE="$ROOT/root/etc/homeproxy-pro/scripts/firewall_post.ut"
UTILS="$ROOT/root/etc/homeproxy-pro/scripts/firewall_utils.uc"
if [ -f "$UTILS" ]; then
pass "firewall_utils.uc exists (where the H1 validators live)"
else
fail "firewall_utils.uc is missing - the H1 validators have nowhere to live"
fi
for fn in ipv4_to_nftarr mac_to_nftarr iface_to_nftarr ports_to_nftarr; do
if grep -qE "^export function $fn\b" "$UTILS"; then
pass "$fn is exported from firewall_utils.uc"
else
fail "$fn is not exported from firewall_utils.uc - the validator the report's H1 demands is missing"
fi
done
# The template must actually import them - having the helpers but not using
# them would still leave a poisoned field landing in the nft output.
for fn in ipv4_to_nftarr mac_to_nftarr iface_to_nftarr ports_to_nftarr; do
if grep -q "\b$fn\b" "$TEMPLATE"; then
pass "firewall_post.ut imports/uses $fn"
else
fail "firewall_post.ut does not reference $fn"
fi
done
# Every field type whose nft set/rule used to be raw `join(', ', control_info.X)`
# or `array_to_nftarr(control_info.X)` must now go through the matching helper.
# The names are taken from the helper function names; the field list is the
# closure of every control_info.X reference that ever appeared bare - kept as
# a literal so a new bare call site is obvious in a diff.
assert_empty "no bare array_to_nftarr(control_info.X) call site remains" \
grep -nE 'array_to_nftarr\(control_info\.' "$TEMPLATE"
assert_empty "no bare join(', ', control_info.X) call site remains" \
grep -nE "join\(', ', control_info\." "$TEMPLATE"
assert_empty "no bare join(', ', split(routing_port,...)) call site remains" \
grep -nE "join\(', ', split\(routing_port" "$TEMPLATE"
# The four field families whose UCI surface area is the report's whole H1:
# IPv4 addresses, MAC addresses, interface names, and ports. Any one of
# these landing verbatim in an nft expression closes a set and reloads the
# whole fw4 stack. Asserted positively: each helper must be applied to every
# field of its family, at the one place the template now validates them.
#
# The validation moved from each call site to a single table (template:
# NFTARR_FIELDS) because gating on the *raw* UCI value while interpolating the
# helper rendered `ip daddr counter return` - an nft syntax error that fails
# the whole transaction - for a list that is non-empty but has no usable entry.
# The guard follows the values: the field must be in the table with the right
# helper, and no call site may interpolate the raw field any more.
#
# Field list is the exact set of control_info.<family> names used by the
# template - if a new field of an existing family is added, this list grows
# with it and the diff is the place to notice.
for f in wan_proxy_ipv4_ips wan_direct_ipv4_ips \
lan_proxy_ipv4_ips lan_direct_ipv4_ips \
lan_global_proxy_ipv4_ips lan_gaming_mode_ipv4_ips; do
if grep -qE "(^|[[:space:],])$f: ipv4_to_nftarr," "$TEMPLATE"; then
pass "ipv4_to_nftarr validates $f (in NFTARR_FIELDS)"
else
fail "ipv4_to_nftarr is not applied to $f (closes a nft set on a bad value)"
fi
done
for f in lan_proxy_mac_addrs lan_direct_mac_addrs \
lan_global_proxy_mac_addrs lan_gaming_mode_mac_addrs; do
if grep -qE "(^|[[:space:],])$f: mac_to_nftarr," "$TEMPLATE"; then
pass "mac_to_nftarr validates $f (in NFTARR_FIELDS)"
else
fail "mac_to_nftarr is not applied to $f (closes a nft set on a bad value)"
fi
done
if grep -qE "(^|[[:space:],])listen_interfaces: iface_to_nftarr," "$TEMPLATE"; then
pass "iface_to_nftarr validates listen_interfaces (in NFTARR_FIELDS)"
else
fail "iface_to_nftarr is not applied to listen_interfaces"
fi
if grep -qE "ports_to_nftarr\(routing_port\)" "$TEMPLATE"; then
pass "ports_to_nftarr covers routing_port"
else
fail "ports_to_nftarr is not applied to routing_port"
fi
# The two derived values the call sites use outside the table.
for derived in "nftarr.listen_interfaces_lo = iface_to_nftarr" "routing_port_set = routing_port ? ports_to_nftarr(routing_port)"; do
if grep -qF "$derived" "$TEMPLATE"; then
pass "derived value is validated: $derived"
else
fail "derived value lost its validator: $derived"
fi
done
# Nothing may interpolate or gate on a raw list field any more: that is exactly
# the shape that rendered an empty expression.
assert_empty "no helper is called on a raw control_info list at a call site" \
grep -nE '(ipv4|ipv6|mac|iface)_to_nftarr\(control_info\.' "$TEMPLATE"
assert_empty "no rule/set gate tests a raw control_info list" \
grep -nE '\{% if \((!?isEmpty\()?control_info\.((lan_|wan_)[a-z0-9_]*_(ips|addrs)|listen_interfaces)\)' "$TEMPLATE"
# Every nftarr.<field> the template reads must exist in the table (the closure
# runs the other way too: a field the call sites use but the table forgot would
# render `{{ nftarr.x }}` as an empty string, which is the same defect).
for f in $(grep -oE 'nftarr\.[a-z0-9_]+' "$TEMPLATE" | sed 's/^nftarr\.//' | sort -u); do
case "$f" in
listen_interfaces_lo)
continue ;;
esac
if grep -qE "(^|[[:space:],{])$f: " "$TEMPLATE"; then
pass "nftarr.$f is declared in NFTARR_FIELDS"
else
fail "nftarr.$f is read by the template but not declared in NFTARR_FIELDS"
fi
done
echo
echo "== guard 12: capabilities stay minimal =="
# Review H2: sing-box on this package runs in tproxy / TUN mode. Both rely
# on the kernel's packet path, not raw sockets, so CAP_NET_RAW is not needed;
# CAP_SYS_PTRACE lets any child process read arbitrary /proc/<pid>/mem, which
# on a process that handles untrusted network traffic is gratuitous attack
# surface. Neither is granted anywhere. inheritable is kept empty because
# nothing the orchestrator spawns needs to inherit caps.
CAPS="$ROOT/root/etc/capabilities/homeproxy-pro.json"
if [ -f "$CAPS" ]; then
pass "homeproxy-pro.json exists"
else
fail "homeproxy-pro.json is missing"
fi
for cap in CAP_SYS_PTRACE CAP_NET_RAW CAP_SYS_ADMIN CAP_DAC_OVERRIDE CAP_SYS_MODULE CAP_SYS_RAWIO; do
if grep -qE "\"$cap\"" "$CAPS"; then
fail "$cap is granted somewhere - review H2 said this should be dropped"
else
pass "$cap is not granted"
fi
done
# inheritable must cover ambient. A capability can only be raised into the
# ambient set while it is in BOTH the permitted and the inheritable set
# (capabilities(7), PR_CAP_AMBIENT_RAISE), and procd/ujail does exactly that
# when it launches the jailed sing-box. The M1 review assumed an empty
# inheritable set was harmless ("every child is a shell that does not need
# elevated caps") and asserted that here - but the raise then fails with EPERM,
# sing-box never gets CAP_NET_BIND_SERVICE, and procd crash-loops:
#
# jail: prctl(PR_CAP_AMBIENT, PR_CAP_AMBIENT_RAISE, 10, 0, 0) failed: Operation not permitted
#
# So the invariant is "ambient is a subset of inheritable", not "inheritable is
# empty". Verified on the test device: with an empty inheritable set
# `homeproxy-pro start` exits 1 and sing-box-c never runs; restoring the two
# entries makes start/reload/stop pass and the instances come up.
AMBIENT="$(awk '/"ambient"/,/]/' "$CAPS" | grep -oE 'CAP_[A-Z_]+' | sort -u)"
INHERITABLE="$(awk '/"inheritable"/,/]/' "$CAPS" | grep -oE 'CAP_[A-Z_]+' | sort -u | tr '\n' ' ')"
MISSING=""
for cap in $AMBIENT; do
case " $INHERITABLE " in
*" $cap "*) ;;
*) MISSING="$MISSING $cap" ;;
esac
done
if [ -n "$MISSING" ]; then
fail "inheritable does not cover ambient:$MISSING - PR_CAP_AMBIENT_RAISE needs both sets, sing-box will crash-loop in the jail"
else
pass "inheritable covers every ambient capability"
fi
# The two caps the package actually needs. Asserted positively so the
# check cannot pass by finding nothing.
for cap in CAP_NET_ADMIN CAP_NET_BIND_SERVICE; do
if grep -qE "\"$cap\"" "$CAPS"; then
pass "$cap is granted"
else
fail "$cap is missing - sing-box needs it for tproxy/TUN"
fi
done
echo
echo "== guard 13: tests/run.sh has no guessed test host =="
# Review M1: tests/run.sh used to default HP_TEST_HOST to a specific LAN
# address, and tests/README.md repeated it. Anyone cloning the repo and
# running `tests/run.sh` would have ssh'd into a stranger's box, with the
# whole checkout unpacked on top. The default is now empty (SKIP), and
# the hardcoded IP is gone from tests/, .github/workflows/ and tests/README.md.
#
# Strip comment lines first: tests/README.md and the workflow headers
# legitimately mention the production-router IP in prose, and the workflow
# has an explicit refusal pattern that matches 192.168.1.1 to refuse it.
# Neither is a silent-connect.
strip_comments() {
# `grep -v` of lines whose first non-whitespace character is `#`.
awk '
{
s = $0
sub(/^[[:space:]]+/, "", s)
if (substr(s, 1, 1) != "#") print FILENAME ":" NR ":" $0
}' "$1"
}
# The specific shape that was wrong: a literal ssh target in a HP_TEST_HOST
# default. The empty default makes the suite skip instead.
BAD_DEFAULT="$(grep -nE 'HP_TEST_HOST[:=].*root@[0-9]' "$ROOT/tests/run.sh" \
| strip_comments /dev/stdin || true)"
if [ -z "$BAD_DEFAULT" ]; then
pass "tests/run.sh does not default HP_TEST_HOST to a hardcoded ssh host"
else
fail "tests/run.sh defaults HP_TEST_HOST to a hardcoded ssh host - the silent-connect trap is back:"
printf ' %s\n' "$BAD_DEFAULT"
fi
# The on-target.yml input default and the workflow-level host pinning.
# Same shape: a literal IP in the `host:` default or in a `HOST` env var.
BAD_INPUT="$(grep -nE "default:.*'[a-z]+@[0-9]" "$ROOT/.github/workflows/on-target.yml" \
| strip_comments /dev/stdin || true)"
if [ -z "$BAD_INPUT" ]; then
pass "on-target.yml input default is not a hardcoded ssh host"
else
fail "on-target.yml input default is a hardcoded ssh host:"
printf ' %s\n' "$BAD_INPUT"
fi
# The empty default is what makes the suite skip rather than silently
# connect to a guessed address. Asserted positively so the check cannot
# pass by finding nothing.
if grep -q 'HOST="${HP_TEST_HOST:-}"' "$ROOT/tests/run.sh"; then
pass "tests/run.sh defaults HP_TEST_HOST to empty (skip rather than connect)"
else
fail "tests/run.sh no longer defaults HP_TEST_HOST to empty - the silent-connect trap is back"
fi
if grep -qE "default: ''" "$ROOT/.github/workflows/on-target.yml"; then
pass "on-target.yml input default is empty"
else
fail "on-target.yml input default is no longer empty - the workflow silently targets an IP again"
fi
echo
echo "== guard 14: wGETVerbose redacts the URL at the source =="
# Review H3: the original fetcher.uc logged a redacted URL but returned the
# raw fetcher stderr, which still had the full URL. Every caller of
# wGETVerbose had to remember to redact the error themselves, and any that
# did not silently leaked the subscription token. The fix moved redaction
# into wGETVerbose itself, so the returned `error` is safe no matter where the
# caller ships it.
#
# The leak is not hypothetical under either fetcher: GNU wget -nv reports the
# target on the failure line, and uclient-fetch reports it by default, before
# it reports anything else -
#
# Downloading 'https://host/path?token=secret'
# HTTP error 404
#
# which is precisely why wGETVerbose does NOT pass -q. Quiet mode would have
# taken the URL out of the message and, with it, the only thing separating an
# HTTP 404 from a connect failure.
HOMEPROXY="$SCRIPTS/homeproxy-pro.uc"
FETCHER="$SCRIPTS/subscription/fetcher.uc"
if grep -qE '^export function redactReason\b' "$HOMEPROXY"; then
pass "redactReason is exported from homeproxy-pro.uc"
else
fail "redactReason is missing - the H3 redaction has no entry point"
fi
# wGETVerbose must call redactReason on the reason string before returning.
# Asserted positively so the check cannot pass by finding nothing (a regex
# in a comment would otherwise be enough to satisfy it).
# awk does not understand \b, so the function name pattern is anchored
# with `export function` and the opening paren instead.
WGET_BODY="$(awk '/^export function wGETVerbose/,/^};/' "$HOMEPROXY")"
if printf '%s' "$WGET_BODY" | grep -q 'redactReason(reason)'; then
pass "wGETVerbose calls redactReason before returning"
else
fail "wGETVerbose does not call redactReason on the reason - the token still leaks"
fi
# The fetcher used to do `redactUrl(url)` on the URL parameter *and* pass
# `result.error` (which carried the raw URL) into the log. Now that
# wGETVerbose redacts internally, the fetcher must not double-process the
# error - it may still redact the URL parameter (it is the subscription
# token, distinct from the error), but must not touch result.error.
FETCHER_LOG="$(awk '/log\(sprintf.*Failed to fetch/,/\);$/' "$FETCHER")"
if printf '%s' "$FETCHER_LOG" | grep -qE "redactUrl\(result\.error"; then
fail "subscription/fetcher.uc redacts result.error again - the redaction is now duplicated and result.error is meant to be already safe"
else
pass "subscription/fetcher.uc does not re-redact result.error"
fi
echo
echo "== guard 15: every RPC whitelist uses index() === -1 ===="
# Review M6: the backend RPC module used two whitelisting spellings side by
# side (`x in [...]` and `index([...], x) === -1`). They both work but the
# reader has to stop and confirm, and one of them silently behaves
# differently when the haystack is a string (it does substring matching
# instead of membership). Pinning the rule to index() means a future method
# has to use the spelling the rest of the file uses.
#
# Coverage boundary: after comments are stripped and the source is flattened,
# the pattern matches a string-literal array with one or more elements in
# either quote style - so `x in ['a']` (single element) and `x in ["a", "b"]`
# (double quotes) are caught, as is a membership test split over several lines.
# Object key checks (`in {}`), `for ... in ...` over a non-literal and a
# mention inside a comment are deliberately not matches.
RPC="$ROOT/root/usr/share/rpcd/ucode/luci.homeproxy-pro"
IN_ARR="$(python3 - "$RPC" <<'PY'
import pathlib, re, sys
# SQ and DQ exist because an escaped quote inside this here-document upsets the
# shell command-substitution scanner and truncates the program, which would
# leave the check matching nothing and silently pass.
SQ, DQ = "'", '"'
COMMENT = r'/\*.*?\*/|//[^\n]*'
TOKENS = re.compile(
DQ + r'(?:\\.|[^"\\])*' + DQ + '|' + SQ + r"(?:\\.|[^'\\])*" + SQ + '|' + COMMENT, re.S)
def blank(match):
return ' ' if match.group(0).startswith('/') else match.group(0)
src = TOKENS.sub(blank, pathlib.Path(sys.argv[1]).read_text(encoding='utf-8', errors='replace'))
flat = re.sub(r'\s+', ' ', src)
# One or more string elements, single- or double-quoted. The old pattern
# required a single-quoted pair, so `x in ['a']` and `x in ["a", "b"]` both
# slipped through. Comments are stripped and the source is flattened first,
# so a membership test split over several lines is seen too.
LIT = '(?:' + DQ + '[^' + DQ + ']*' + DQ + '|' + SQ + '[^' + SQ + ']*' + SQ + ')'
for m in re.finditer(r'\bin\s*\[\s*' + LIT + r'(?:\s*,\s*' + LIT + r')*\s*\]', flat):
print(m.group(0))
PY
)" || IN_ARR="__SCAN_FAILED__"
if [ "$IN_ARR" = "__SCAN_FAILED__" ]; then
fail "the 'in [...]' scan could not run - fix the guard before trusting a pass"
elif [ -z "$IN_ARR" ]; then
pass "no array-membership 'in [...]' remains in luci.homeproxy-pro"
else
fail "array-membership 'in [...]' remains in luci.homeproxy-pro - the report's M6 said unify on index():"
printf ' %s\n' "$IN_ARR"
fi
# Each whitelisting call site must use index() === -1 (or !== -1 for the
# subset checks). Asserted positively so the check cannot pass by finding
# nothing (a regex in a comment would otherwise be enough).
if grep -qE "index\(\[".*"\], req\.args\?\.type\) === -1" "$RPC"; then
pass "luci.homeproxy-pro uses index() === -1 for whitelist checks"
else
fail "luci.homeproxy-pro no longer uses index() === -1 for whitelist checks - did someone reintroduce the in-style?"
fi
echo
echo "== guard 16: every shell argument goes through shellQuote() =="
# Review M6: shellQuote() is the one helper that wraps an arbitrary string
# into single quotes that the shell cannot parse as syntax. Every script
# argument that the shell sees has to come out of shellQuote(); the only
# exception is a literal constant with no interpolation. Today the
# generators used string concatenation (`'rm -rf ' + tmp`), the rpcd
# module had a local lowercase `shellquote()` that the report caught, and
# one site interpolated `${req.args?.params}` raw. This guard pins all of
# those to the imported shellQuote() so a future shell call cannot
# quietly escape the rule.
#
# The check walks every `system(...)` / `popen(...)` invocation under
# $SCRIPTS and the rpcd tree. A call is "compliant" if it either:
# (a) contains no `${...}` interpolation at all (literal command), or
# (b) contains at least one `shellQuote(` call somewhere in its
# arguments, so every interpolation goes through it.
# grep -E0 is unavailable; split the check into two passes so a compliant
# line is not double-counted.
# Pass 1: shell calls with no interpolation at all are fine.
SHELL_CALLS="$(grep -rEn '(^|[^A-Za-z_])(system|popen)\(' \
"$SCRIPTS" "$RPC" 2>/dev/null \
| grep -vE '/\*|^[^:]+:[^:]+:[[:space:]]*//' || true)"
# Pass 2: of those, lines that contain `${` (interpolation) must also
# contain `shellQuote(` somewhere in the same line. ${} inside a string
# means the value reached the shell unquoted.
UNQUOTED="$(printf '%s\n' "$SHELL_CALLS" | python3 -c '
import sys, re
# Two styles reach the shell unquoted:
# (a) template literal with `${someVar}` - luci.homeproxy-pro
# (b) string concatenation `+ someVar +` - the generator scripts
# A line is compliant if shellQuote() appears on it; both styles are
# caught with one rule because the helper is the same either way.
template = re.compile(r"\$\{[A-Za-z_]")
concat = re.compile(r"\+ +[A-Za-z_][A-Za-z_0-9]*(?!\()")
quote = re.compile(r"shellQuote\(")
for raw in sys.stdin:
line = raw.rstrip("\n")
# Strip the "<file>:<lineno>:" prefix the grep produced.
body = line.split(":", 2)[2] if line.count(":") >= 2 else line
if not (template.search(body) or concat.search(body)):
continue
if not quote.search(body):
print(line)
')" || UNQUOTED="__SCAN_FAILED__"
# Two exclusions the rule has to live with:
# - update_subscriptions.uc uses sprintf() with shellQuote() arguments,
# so `${shellQuote(x)}` is fine but the awk heuristic only sees it as
# `${shellQuote(x)}` and considers it quoted. sprintf() is a sibling
# function that builds the command before passing it to system(); the
# guard's grep on the source line therefore catches every other case
# without needing to descend into sprintf.
# - firewall_pre.uc writes nft fragments to disk rather than passing
# them to a shell, so its system() calls only see literal commands.
if [ "$UNQUOTED" = "__SCAN_FAILED__" ]; then
fail "the shellQuote() coverage scan could not run - fix the guard before trusting a pass"
elif [ -z "$UNQUOTED" ]; then
pass "every shell arg with \${...} interpolation goes through shellQuote()"
else
fail "shell args with \${...} interpolation bypass shellQuote():"
printf ' %s\n' "$UNQUOTED" | head -20
fi
# The local lowercase `shellquote()` placeholder must be gone - it shadows
# the imported shellQuote() and would silently no-op on a future call site.
LOWER="$(grep -rnE '\bshellquote\(' "$SCRIPTS" "$RPC" 2>/dev/null || true)"
if [ -z "$LOWER" ]; then
pass "the local lowercase shellquote() placeholder is gone"
else
fail "local lowercase shellquote() is still referenced - replace with the imported shellQuote():"
printf ' %s\n' "$LOWER"
fi
echo
echo "== guard 17: resource update has multi-mirror fallback =="
# Review M7: update_resources.sh used to hardcode fastly.jsdelivr.net. When
# that CDN was unreachable (or shared the same rate limit / block as the
# user's network), the only path to "successfully updated" was for the user
# to switch the package's CDN manually - and there was no UI surface for
# it. Walking a fallback list at download time keeps the script correct
# for the common case (fastly is up) and useful in the failure case
# (gcore, cdn, or raw.githubusercontent.com catches it). The list lives
# in the script's MIRRORS variable; the order is part of the contract.
UPDATE_SCRIPT="$ROOT/root/etc/homeproxy-pro/scripts/update_resources.sh"
for mirror in fastly.jsdelivr.net gcore.jsdelivr.net cdn.jsdelivr.net raw.githubusercontent.com; do
if grep -qE "\b$mirror\b" "$UPDATE_SCRIPT"; then
pass "update_resources.sh mentions $mirror"
else
fail "update_resources.sh is missing $mirror in its mirror list"
fi
done
# The fallback must actually iterate - asserting just that the names
# appear would pass on a one-line comment.
if grep -qE "for [a-zA-Z_]+ in \\\$MIRRORS" "$UPDATE_SCRIPT"; then
pass "update_resources.sh iterates over the mirror list"
else
fail "update_resources.sh does not iterate over the mirror list - the fallback is not wired"
fi
echo
echo "== guard 18: resource update exposes last-successful timestamp =="
# The .updated_at file is what the UI shows as "(last updated ...)".
# The .ver file holds the upstream commit date, which is not what the
# user wants - they want to know when *this router* last succeeded.
if grep -qE '\$RESOURCES_DIR/\$listtype\.updated_at' "$UPDATE_SCRIPT"; then
pass "update_resources.sh writes the local success timestamp to <type>.updated_at"
else
fail "update_resources.sh does not persist the local success timestamp - the UI has nothing to show"
fi
# The RPC must return both version and updated_at. Asserted positively
# so the guard cannot pass by finding nothing.
LUCI="$ROOT/root/usr/share/rpcd/ucode/luci.homeproxy-pro"
if grep -qE "resources_get_version" "$LUCI" && grep -qE "\.updated_at" "$LUCI"; then
pass "resources_get_version returns updated_at"
else
fail "resources_get_version does not return updated_at - the UI cannot show \"(last updated ...)\""
fi
# The UI must read and display it. Without this, the RPC field is
# unused and the user sees nothing new.
STATUS_JS="$ROOT/htdocs/luci-static/resources/view/homeproxy-pro/status.js"
if grep -qE "res\.updated_at" "$STATUS_JS"; then
pass "status.js renders the last-updated timestamp"
else
fail "status.js does not render res.updated_at - the RPC field is unused"
fi
echo
echo "== guard 19: acllist_write rejects bad lines with a line number =="
# Review L1: acllist_write used to return "invalid character in domain list"
# with no offset, so a 200-line list with one bad row forced the user to
# bisect by hand. The rejection now names the line, and the UI hint tells
# the user what characters are banned. Pinned by the guard so the line
# number cannot quietly regress.
LUCI="$ROOT/root/usr/share/rpcd/ucode/luci.homeproxy-pro"
ACLLIST_BODY="$(awk '/^[[:space:]]+acllist_write:/,/^[[:space:]]+},$/' "$LUCI")"
if printf '%s' "$ACLLIST_BODY" | grep -qE "line \\\$\\{i \\+ 1\\}"; then
pass "acllist_write reports the offending line number"
else
fail "acllist_write does not report a line number - the L1 fix is gone"
fi
# The UI hint (one per textarea) is the other half: the user sees the
# description *before* trying to save. Both proxy_list and direct_list
# textareas must carry the hint; assert positively so the guard cannot
# pass by finding nothing.
ACCESS="$ROOT/htdocs/luci-static/resources/view/homeproxy-pro/client/access.js"
PROXY_HINT="$(awk '/_proxy_domain_list/,/description\s*=/' "$ACCESS" \
| grep -c 'One domain per line')"
DIRECT_HINT="$(awk '/_direct_domain_list/,/description\s*=/' "$ACCESS" \
| grep -c 'One domain per line')"
if [ "$PROXY_HINT" -ge 1 ]; then
pass "access.js proxy_domain_list textarea carries the L1 hint"
else
fail "access.js proxy_domain_list textarea is missing the L1 hint - the user is back to guessing which characters are allowed"
fi
if [ "$DIRECT_HINT" -ge 1 ]; then
pass "access.js direct_domain_list textarea carries the L1 hint"
else
fail "access.js direct_domain_list textarea is missing the L1 hint"
fi
# No shipped file may cite docs/. The whole directory is gitignored, so every
# pointer to it is a dead link in a fresh clone - that is how README.md
# came to reference an agent guide and an improvement plan that no
# contributor can open.
#
# The pattern is deliberately *shape*-based, not a list of names. An earlier
# version enumerated the offending documents
# (adr|architecture-improvement-plan|homeproxy_architecture_refactor_agent_guide|
# audit-report|next-step-plan) and therefore only ever caught those five: the
# next document to be written - the gap analysis, the dated review reports -
# walked straight past it, and a dozen comments across the generators, the
# views and the test suite kept citing
# `docs/linux.json 与 pro 的差距分析.md` with no gate noticing.
# Matching `docs/` followed by anything catches every present and future name.
#
# The anchor is what keeps it from crying wolf. `htdocs/` contains the
# substring `docs/`, and so does the `/docs/` of an external URL such as
# https://github.com/anytls/anytls-go/blob/v0.0.8/docs/uri_scheme.md - both
# are legitimate and must not be flagged. Requiring the character before
# `docs/` to be neither alphanumeric nor `_`, `.`, `/`, `-` or `:` excludes
# exactly those two shapes and keeps every real citation.
DEAD_DOCS="$(grep -rlE '(^|[^:A-Za-z0-9_./-])docs/[^[:space:]]' \
"$ROOT/README.md" "$ROOT/root" "$ROOT/htdocs" 2>/dev/null || true)"
if [ -z "$DEAD_DOCS" ]; then
pass "no shipped file cites the gitignored docs/ tree"
else
fail "these shipped files cite docs/ (gitignored, absent from every clone):"
printf '%s\n' "$DEAD_DOCS"
fi
# The same rule for the test tree, minus this file: a dead citation inside a
# test is just as unresolvable for a contributor reading the failure, and the
# generator suite carried six of them.
DEAD_DOCS_TESTS="$(grep -rlE '(^|[^:A-Za-z0-9_./-])docs/[^[:space:]]' \
"$ROOT/tests" 2>/dev/null | grep -v 'tests/arch-guard\.sh$' || true)"
if [ -z "$DEAD_DOCS_TESTS" ]; then
pass "no test file cites the gitignored docs/ tree either"
else
fail "these test files cite docs/ (gitignored, absent from every clone):"
printf '%s\n' "$DEAD_DOCS_TESTS"
fi
echo
echo "== guard 21: dnsmasq snippet writer is incremental =="
# Review L6: hp_dnsmasq_write_snippets rewrote the snippet set and restarted
# dnsmasq on every reload. A dnsmasq restart flushes every client's DNS
# cache, and the lists only change once a day at best - so an ordinary
# reload was a LAN-wide cache flush for nothing. The writer now renders
# into a staging directory, compares, and restarts only on a real change.
# tests/runtime/test_dns_snippets.sh pins the behaviour from the outside;
# this guard pins the two structural pieces that make it possible, so a
# future edit cannot quietly drop back to unconditional rewrite.
DNS_SH="$ROOT/root/etc/homeproxy-pro/scripts/runtime/dns.sh"
if grep -qE '^hp_dnsmasq_render_snippets\(\)' "$DNS_SH"; then
pass "dns.sh has a side-effect-free snippet renderer"
else
fail "dns.sh has no separate renderer - the staging/compare split is gone"
fi
if grep -qE '^hp_dnsmasq_dir_differs\(\)' "$DNS_SH"; then
pass "dns.sh has a directory comparison helper"
else
fail "dns.sh has no directory comparison helper - the skip branch cannot work"
fi
# The renderer must not touch the live directory: it takes a stage dir as
# its first argument and writes only there.
RENDER_BODY="$(awk '/^hp_dnsmasq_render_snippets\(\)/,/^}$/' "$DNS_SH")"
if printf '%s' "$RENDER_BODY" | grep -qE '\$stage/'; then
pass "the renderer writes into the staging directory"
else
fail "the renderer does not write into a staging directory"
fi
if printf '%s' "$RENDER_BODY" | grep -qE 'dnsmasq restart'; then
fail "the renderer restarts dnsmasq - it is supposed to be side-effect free"
else
pass "the renderer does not restart dnsmasq"
fi
# The skip branch must exist and must be reachable before the restart.
if grep -qE 'unchanged, skipping the restart' "$DNS_SH"; then
pass "the skip branch is present"
else
fail "the skip branch is gone - an unchanged snippet set would restart dnsmasq again"
fi
# The include file is one line of `conf-dir=`; `echo -e` renders a literal
# "-e " prefix on a POSIX sh and dnsmasq then refuses the file. printf is
# the portable spelling and the only one used here.
if grep -qE 'echo -e "conf-dir=' "$DNS_SH"; then
fail "dns.sh writes the conf-dir include with 'echo -e' - a POSIX sh emits a literal '-e ' prefix"
else
pass "the conf-dir include is written with printf, not 'echo -e'"
fi
echo
echo "== guard 22: client tab modules return a LuCI class =="
# LuCI instantiates every module it requires and rejects anything that is not
# a Class subclass:
#
# _class = _factory.apply(...);
# if (!Class.isSubclass(_class))
# error('TypeError', '"%s" factory yields invalid constructor', name);
#
# The M5 split shipped six view.homeproxy-pro.client.* modules returning a plain
# object (`return { render }`), so every tab threw and no browser could render
# the client page. Nothing caught it: the modules parse, and the snapshot
# harness has its own loader that happily accepts a plain object.
for f in "$VIEWS"/view/homeproxy-pro/client/*.js; do
[ -f "$f" ] || continue
if grep -qE '^return baseclass\.extend\(' "$f"; then
pass "$(basename "$f") returns a LuCI class"
else
fail "$(basename "$f") does not return baseclass.extend(...) - luci.js rejects it as an invalid constructor"
fi
done
echo
echo "== guard 23: an hp method that reads \`this\` is bound to hp =="
# A method that reaches a sibling through `this` only works when the receiver
# is the module object itself. Six call sites bound loadModalTitle() to a view
# instance, so `this.escapeTitleText` was undefined and every edit modal for a
# section that has a label threw a TypeError; four more did the same to
# uploadCertificate(), so no certificate upload ever reached its RPC. Nothing
# caught it: the form snapshot does not record function-valued properties, and
# the frontend tests call hp.method() directly - where `this` *is* hp, so the
# only broken path was the one production uses.
#
# The rule: when a view binds an hp method through L.bind(), and that method's
# body reads `this`, the receiver has to be `hp`.
BOUND_WRONG="$(python3 - "$ROOT" <<'PY'
import re, sys, pathlib
root = pathlib.Path(sys.argv[1])
hp = (root / 'htdocs/luci-static/resources/homeproxy-pro.js').read_text(encoding='utf-8')
# Top-level method -> does its body read `this`?
uses_this, cur, body = set(), None, []
for line in hp.split('\n'):
m = re.match(r'^\t([A-Za-z_$][\w$]*)\(', line)
if m and cur is None:
cur, body = m.group(1), []
if cur is not None:
body.append(line)
if line == '\t},':
if any('this.' in b for b in body):
uses_this.add(cur)
cur = None
bad = []
for f in sorted((root / 'htdocs').rglob('*.js')):
if f.name == 'homeproxy-pro.js':
continue
for n, line in enumerate(f.read_text(encoding='utf-8').split('\n'), 1):
for meth, recv in re.findall(r'L\.bind\(hp\.([A-Za-z_$][\w$]*),\s*([A-Za-z_$][\w$]*)', line):
if meth in uses_this and recv != 'hp':
bad.append(f'{f.relative_to(root)}:{n}: L.bind(hp.{meth}, {recv}, ...)')
print('\n'.join(bad))
PY
)" || BOUND_WRONG="__SCAN_FAILED__"
if [ "$BOUND_WRONG" = "__SCAN_FAILED__" ]; then
fail "the bound-receiver scan could not run - fix the guard before trusting a pass"
elif [ -z "$BOUND_WRONG" ]; then
pass "every receiver-dependent hp method is bound to hp"
else
fail "an hp method that reads \`this\` is bound to a foreign receiver:"
printf ' %s\n' "$BOUND_WRONG"
fi
echo
echo "== guard 24: dns-in listens on loopback only =="
# dns-in is a `direct` inbound, and a direct inbound forwards a connection
# without consulting the route rules. It used to listen on '::' - every
# interface - so any host that reached the DNS port had its connection dialled
# straight out; a router log showed blocked addresses timing out through the
# built-in `direct` outbound while every route rule named main-out. The only
# client that has any business at that port is the local resolver: dnsmasq is
# pointed at 127.0.0.1#<dns_port> by runtime/dns.sh, and it is the only DNS
# entry point on the LAN.
if awk "/tag: 'dns-in'/,/listen_port/" "$SCRIPTS/generator/inbound.uc" \
| grep -qF "listen: '127.0.0.1'"; then
pass "dns-in listens on 127.0.0.1 only"
else
fail "dns-in does not listen on 127.0.0.1 - the DNS port is reachable from other hosts, and a direct inbound bypasses the routing rules"
fi
echo
echo "== guard 25: executeCommand() arguments are quoted by the caller =="
# executeCommand() joins its arguments into one shell command line, so a bare
# variable in an argument reaches /bin/sh unquoted. Guard 16 only walks
# `system()` / `popen()` lines, and the busiest shell boundary in the package -
# wGETVerbose(), which fetches subscription bodies - builds its command through
# executeCommand() instead, so the guard could not see it. Rule: every ${...}
# interpolation in an executeCommand() argument is shellQuote()d, or a pure
# numeric constant (the `head -c` limit).
EXEC_UNQUOTED="$(python3 - "$SCRIPTS" <<'PY'
import re, sys, pathlib
bad = []
for f in sorted(pathlib.Path(sys.argv[1]).rglob('*.uc')):
for n, line in enumerate(f.read_text(encoding='utf-8').split('\n'), 1):
if 'executeCommand(' not in line or line.lstrip().startswith('*'):
continue
for expr in re.findall(r'\$\{([^{}]*)\}', line):
e = expr.strip()
if e.startswith('shellQuote('):
continue
if re.fullmatch(r'[A-Za-z_]\w*\s*[-+*/]\s*\d+', e):
continue
bad.append(f'{f.name}:{n}: ${{{e}}}')
print('\n'.join(bad))
PY
)" || EXEC_UNQUOTED="__SCAN_FAILED__"
if [ "$EXEC_UNQUOTED" = "__SCAN_FAILED__" ]; then
fail "the executeCommand() interpolation scan could not run - fix the guard before trusting a pass"
elif [ -z "$EXEC_UNQUOTED" ]; then
pass "every executeCommand() interpolation is shellQuote()d or numeric"
else
fail "an executeCommand() argument reaches the shell unquoted:"
printf ' %s\n' "$EXEC_UNQUOTED"
fi
echo
echo "== guard 26: ucode sources carry no PCRE-only regex syntax =="
# ucode's regex engine is not PCRE, and it does not reject the unsupported
# construct at parse time: `(?:...)` compiles fine and then fails when the
# literal is evaluated ("Repetition not preceded by valid expression"). That
# is how r11 shipped a luci.homeproxy-pro whose resources_get_version threw on
# every call - the build, `ucode -c` and every syntax check stayed green while
# the resources panel rendered "undefined". One grep keeps it out.
PCRE_ONLY="$(grep -rn '(?:' "$ROOT/root" --include='*.uc' --include='*.ut' 2>"/dev/null" \
| grep -vE ':[0-9]+:[[:space:]]*(\*|/\*|//|#)' || true)"
if [ -z "$PCRE_ONLY" ]; then
pass "no non-capturing groups in ucode sources"
else
fail "ucode does not support (?: ...) - it compiles and then fails at run time:"
printf ' %s\n' "$PCRE_ONLY"
fi
echo
echo "== guard 27: generator/ is a pure function of its arguments =="
# generator/client.uc was documented as a pure function while it called ubus for
# the WAN resolver and readfile() for the two domain-resource lists. Generation
# was therefore not reproducible - two runs could differ if the WAN lease changed
# - and reload's preflight config was not provably the artifact start_service
# regenerated. The impure boundary is the CLI shells (scripts/generate_*.uc);
# every module under generator/ takes its environment as an argument. See
# generator/context.uc.
GEN_IMPURE="$(grep -rnE "from '(ubus|fs)'" "$SCRIPTS/generator" --include='*.uc' 2>"/dev/null" \
| grep -vE ':[0-9]+:[[:space:]]*(\*|/\*|//|#)' || true)"
if [ -z "$GEN_IMPURE" ]; then
pass "no generator module imports ubus or fs"
else
fail "a generator module reaches for live router state instead of taking it as an argument:"
printf ' %s\n' "$GEN_IMPURE"
fi
echo
echo "== guard 28: the subscription run reads its state inside the lock =="
# update_subscriptions.uc used to read the domain model and the recovery
# snapshot at module scope, and only then try to take the lock. A run that read
# while another one held the lock and then acquired it after that one finished
# committed against the base it had read earlier and, on failure, restored a
# snapshot older than the run that had just committed. The reads must therefore
# live inside load_locked_state(), and that function must only be called after
# acquire_lock() succeeds.
#
# Two checks: no configuration read may appear at module scope (column 0), and
# the load_locked_state() call must come after the acquire_lock() call.
SNAP_ORDER="$(python3 - "$SCRIPTS/update_subscriptions.uc" <<'PY'
import re, sys
lines = open(sys.argv[1], encoding='utf-8').read().split('\n')
read = re.compile(r'\b(cursor\(|Loader\.load\(|uci\.load\(|readfile\(CONFIG_FILE\))')
problems = []
lock = lls = None
for n, line in enumerate(lines, 1):
if line.lstrip().startswith(('*', '/*', '#')):
continue
if line[:1] not in ('', ' ', '\t') and read.search(line):
problems.append('line %d reads configuration at module scope: %s' % (n, line.strip()))
if 'function acquire_lock' in line:
continue
if 'function load_locked_state' in line:
continue
if lock is None and 'acquire_lock()' in line:
lock = n
if lls is None and 'load_locked_state()' in line:
lls = n
if lock is None:
problems.append('no acquire_lock() call site')
if lls is None:
problems.append('no load_locked_state() call site')
elif lock is not None and lls < lock:
problems.append('load_locked_state() at line %d runs before the lock at line %d' % (lls, lock))
print('\n'.join(problems))
PY
)" || SNAP_ORDER="__SCAN_FAILED__"
if [ "$SNAP_ORDER" = "__SCAN_FAILED__" ]; then
fail "the lock-ordering scan could not run - fix the guard before trusting a pass"
elif [ -z "$SNAP_ORDER" ]; then
pass "the domain model and the recovery snapshot are read after the lock"
else
fail "the subscription run reads state outside the lock:"
printf ' %s\n' "$SNAP_ORDER"
fi
echo
echo "== guard 29: the certificate path policy is identical in both layers =="
# TLS certificate_path / key_path had two different policies: the LuCI form
# offered /etc/homeproxy-pro/certs/, /etc/acme/ and /etc/ssl/, while the backend
# validator only knew /etc/homeproxy-pro/ and /tmp/homeproxy_. A certificate the
# user picked from /etc/ssl/ was therefore accepted by the UI, dropped by
# buildTLSObject(), and the listener failed with nothing pointing at the path.
# Both layers now read the same list - CERT_PATH_ROOTS in homeproxy-pro.uc and
# HP_CERT_PATH_ROOTS in homeproxy-pro.js - and this guard keeps the two copies in
# step, because nothing else can see across the JS/ucode boundary.
CERT_ROOTS="$(python3 - "$SCRIPTS/homeproxy-pro.uc" "$VIEWS/homeproxy-pro.js" <<'PY'
import re, sys
def roots(path, pattern, label):
text = open(path, encoding='utf-8').read()
m = re.search(pattern, text)
if not m:
return None, '%s: could not find the root list' % label
return re.findall(r"'([^']+)'", m.group(1)), None
problems = []
backend, err = roots(sys.argv[1], r'\bCERT_PATH_ROOTS\s*=\s*\[([^\]]*)\]', 'homeproxy-pro.uc')
problems += [err] if err else []
frontend, err = roots(sys.argv[2], r'\bHP_CERT_PATH_ROOTS\s*=\s*\[([^\]]*)\]', 'homeproxy-pro.js')
problems += [err] if err else []
if not problems and backend != frontend:
problems.append('homeproxy-pro.uc %s != homeproxy-pro.js %s' % (backend, frontend))
print('\n'.join(problems))
PY
)" || CERT_ROOTS="__SCAN_FAILED__"
if [ "$CERT_ROOTS" = "__SCAN_FAILED__" ]; then
fail "the cert-roots scan could not run - fix the guard before trusting a pass"
elif [ -z "$CERT_ROOTS" ]; then
pass "the frontend and backend certificate path roots agree"
else
fail "the certificate path policy differs between the layers:"
printf ' %s\n' "$CERT_ROOTS"
fi
echo
echo "== guard 30: the ACL grants no file execution =="
# The subscription button used fs.exec_direct() on
# /etc/homeproxy-pro/scripts/update_subscriptions.uc, and that is the only reason
# the ACL carried `"exec"` for a root-owned path: a browser session authorised
# to execute a file, rather than to call one named method. The updater is a ubus
# method now (update_subscriptions), so no path in this ACL may be executable -
# and no view may try, because the exec would be denied at run time.
ACL_EXEC="$(grep -n '"exec"' "$ACL" 2>"/dev/null" || true)"
if [ -z "$ACL_EXEC" ]; then
pass "the ACL grants no executable file path"
else
fail "the ACL still grants file execution:"
printf ' %s\n' "$ACL_EXEC"
fi
# Block comments are stripped first: the call site's own explanatory comment
# names fs.exec_direct(), and a naive grep matched that prose - the same trap
# the frontend RPC boundary test documents.
EXEC_DIRECT="$(python3 - "$VIEWS" <<'PY'
import pathlib, re, sys
bad = []
for f in sorted(pathlib.Path(sys.argv[1]).rglob('*.js')):
src = re.sub(r'/\*[\s\S]*?\*/', '', f.read_text(encoding='utf-8'))
for n, line in enumerate(src.split('\n'), 1):
if 'exec_direct' in line:
bad.append('%s:%d: %s' % (f.relative_to(sys.argv[1]), n, line.strip()))
print('\n'.join(bad))
PY
)" || EXEC_DIRECT="__SCAN_FAILED__"
if [ "$EXEC_DIRECT" = "__SCAN_FAILED__" ]; then
fail "the fs.exec_direct scan could not run - fix the guard before trusting a pass"
elif [ -z "$EXEC_DIRECT" ]; then
pass "no view runs a file directly (the ACL grants no exec right)"
else
fail "a view still calls fs.exec_direct, which needs the exec grant this ACL no longer has:"
printf ' %s\n' "$EXEC_DIRECT"
fi
echo
echo "== guard 31: the sing-box floor is identical in the gate, the testbed and the docs =="
# The package cannot express "sing-box >= 1.14" as an OpenWrt dependency: the
# build system has no version-constrained depends for a package the feed
# builds independently, so an install on a 1.13 source succeeds and the service
# only refuses at start time (hp_require_singbox). That makes the floor a
# number stated in four places, any of which a later edit can desync:
#
# runtime/service.sh hp_require_singbox() the enforcement
# tests/toolchain/* SINGBOX_VERSION what the suite tests against
# Makefile +sing-box the dependency itself
# README.md the stated requirement
#
# The testbed pin matters most: if it moved below the gate, the generator cases
# would feed configs to a binary the package rejects and the suite would pass
# on a version users cannot run.
GATE_MAJOR="$(grep -oE 'sb_major" -lt [0-9]+' "$RUNTIME/service.sh" 2>"/dev/null" | grep -oE '[0-9]+$' | head -1)"
GATE_MINOR="$(grep -oE 'sb_minor" -lt [0-9]+' "$RUNTIME/service.sh" 2>"/dev/null" | grep -oE '[0-9]+$' | head -1)"
TESTBED_VERSION="$(sed -n 's/^SINGBOX_VERSION="\([0-9][0-9.]*\)"$/\1/p' \
"$ROOT/tests/toolchain/build-ucode-linux.sh" 2>"/dev/null" | head -1)"
TESTBED_FLOOR="${TESTBED_VERSION%.*}"
FLOOR_PROBLEMS=""
[ -n "$GATE_MAJOR" ] && [ -n "$GATE_MINOR" ] || FLOOR_PROBLEMS="$FLOOR_PROBLEMS no floor in runtime/service.sh"
[ -n "$TESTBED_VERSION" ] || FLOOR_PROBLEMS="$FLOOR_PROBLEMS no SINGBOX_VERSION in the testbed script"
if [ -n "$GATE_MAJOR" ] && [ -n "$TESTBED_VERSION" ] && [ -n "$GATE_MINOR" ]; then
[ "$GATE_MAJOR.$GATE_MINOR" = "$TESTBED_FLOOR" ] \
|| FLOOR_PROBLEMS="$FLOOR_PROBLEMS the gate refuses below $GATE_MAJOR.$GATE_MINOR but the testbed pins $TESTBED_VERSION"
fi
grep -q '+sing-box' "$ROOT/Makefile" 2>"/dev/null" \
|| FLOOR_PROBLEMS="$FLOOR_PROBLEMS the Makefile does not depend on +sing-box"
if [ -n "$GATE_MAJOR" ] && [ -n "$GATE_MINOR" ]; then
# The requirement section specifically, not "1.14 appears somewhere in the
# README": the title and the comparison table mention 1.14 too, and a guard
# that any of those can satisfy would pass on a README whose requirements
# no longer state the floor at all - which is exactly how the earlier
# version of this check behaved.
README_REQ="$(awk '/^## 运行要求/{f=1;next} /^## /{f=0} f' "$ROOT/README.md" 2>"/dev/null")"
if [ -z "$README_REQ" ]; then
FLOOR_PROBLEMS="$FLOOR_PROBLEMS README.md has no '## 运行要求' section"
elif ! printf '%s\n' "$README_REQ" | grep -qE "sing-box[^0-9]{0,6}$GATE_MAJOR\\.$GATE_MINOR"; then
FLOOR_PROBLEMS="$FLOOR_PROBLEMS the README requirements do not state the $GATE_MAJOR.$GATE_MINOR floor"
fi
fi
if [ -z "$FLOOR_PROBLEMS" ]; then
pass "gate, testbed pin, dependency and README all say sing-box $GATE_MAJOR.$GATE_MINOR"
else
fail "the sing-box floor is inconsistent:$FLOOR_PROBLEMS"
fi
echo
echo "== guard 32: the adapter's shared field table cannot overwrite a runtime field =="
# build_outbound() builds the literal `{ type, tag, routing_mark }` and then
# applies COMMON_FIELDS over it, so a key listed in that table wins - even when
# it is `null`, after which removeBlankAttrs() drops the field entirely. That
# is how routing_mark (the SO_MARK that keeps sing-box's own proxy connection
# out of the nft redirect chain) disappeared from every outbound while
# `sing-box check` stayed green: on macOS the field is unknown, so the suite
# could not see it. The two key sets have to stay disjoint; a runtime-owned
# field belongs in build_outbound()'s literal (or in its `mark` parameter),
# never in this table.
ADAPTER="$SCRIPTS/config/adapter.uc"
COMMON_KEYS="$(awk '/^const COMMON_FIELDS = \{/{f=1;next} f && /^\};/{exit} f' "$ADAPTER" 2>"/dev/null" \
| sed -n 's/^\t\([a-z_][a-z_0-9]*\):.*/\1/p' | sort -u)"
LITERAL_KEYS="$(awk '/^function build_outbound\(/{f=1;next} f && /COMMON_FIELDS\)/{exit} f' "$ADAPTER" 2>"/dev/null" \
| sed -n 's/^\t\t\([a-z_][a-z_0-9]*\):.*/\1/p' | sort -u)"
OVERLAP="$(printf '%s\n%s\n' "$COMMON_KEYS" "$LITERAL_KEYS" | sort | uniq -d | tr '\n' ' ')"
if [ -z "$COMMON_KEYS" ] || [ -z "$LITERAL_KEYS" ]; then
fail "could not read both field sets from config/adapter.uc (COMMON_FIELDS: $([ -n "$COMMON_KEYS" ] && echo ok || echo empty), build_outbound literal: $([ -n "$LITERAL_KEYS" ] && echo ok || echo empty))"
elif [ -n "$OVERLAP" ]; then
fail "COMMON_FIELDS lists a field build_outbound() already set, so it is overwritten: $OVERLAP"
else
pass "no key is set by both build_outbound()'s literal and COMMON_FIELDS"
fi
echo
echo "== guard 33: both packaging paths declare every Makefile conffile =="
# The release artifacts are built by .github/build-pkg.sh, which parses the
# Makefile itself - so the Makefile's conffiles block has to reach both package
# formats. It used to filter that list down to /etc/config/<app> for the apk
# and the ipk, which meant the released packages protected one path while the
# feed build protected all nine; the resource lists the device updates were
# then overwritten by an upgrade. A filtered list or a single write means the
# two packaging paths have diverged again.
BUILD_PKG="$ROOT/.github/build-pkg.sh"
CONF_FILTERED="$(grep -n 'CONFFILES\[@\]' "$BUILD_PKG" 2>"/dev/null" | grep 'grep -x' || true)"
CONF_WRITES="$(grep -c 'CONFFILES\[@\]' "$BUILD_PKG" 2>"/dev/null")"
if [ -n "$CONF_FILTERED" ]; then
fail "build-pkg.sh filters the conffile list instead of shipping it whole: $CONF_FILTERED"
elif [ "${CONF_WRITES:-0}" -lt 2 ]; then
fail "build-pkg.sh writes the conffile list ${CONF_WRITES:-0} time(s); the apk and the ipk each need it"
else
pass "the whole conffile list reaches both the apk and the ipk"
fi
echo
echo
echo "== guard 34: the bundled resource versions use the writers' format =="
# Both writers - scripts/update_resources.sh on the device and
# .github/update-geodata.sh for the package - store `<YYYY-MM-DD> <git-sha>`,
# and the reader (rpcd ucode, resources_get_version) splits the value on the
# space. The package shipped bare timestamps instead (20260704060757), so the
# status page presented a 14-digit number as the upstream version and the
# "already at the latest version" comparison could never match. The date/sha
# shape is checked with case/parameter expansion rather than a grep interval,
# which busybox grep may not support.
RES_VER_BAD=""
for f in "$ROOT"/root/etc/homeproxy-pro/resources/*.ver; do
[ -f "$f" ] || continue
RES_VER="${f##*/}"
RES_VER_VALUE="$(cat "$f" 2>"/dev/null")"
RES_VER_SHA="${RES_VER_VALUE##* }"
case "$RES_VER_VALUE" in
[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]' '*) ;;
*) RES_VER_BAD="$RES_VER_BAD $RES_VER(missing date)"; continue ;;
esac
if [ "${#RES_VER_SHA}" -ne 40 ] || printf '%s' "$RES_VER_SHA" | grep -q '[^0-9a-f]'; then
RES_VER_BAD="$RES_VER_BAD $RES_VER(missing sha)"
fi
done
if [ -n "$RES_VER_BAD" ]; then
fail "bundled .ver files are not '<date> <sha>':$RES_VER_BAD"
else
pass "every bundled .ver file is '<date> <sha>'"
fi
echo
echo "== guard 35: mock copies of redactUrl match the real one =="
# tests/ucode/mocks/*.uc carry verbatim copies of redactUrl() so the parser and
# fetcher tests can run without homeproxy-pro.uc's other dependencies. A copy is
# only worth having while it is identical: when the real function started
# masking the URL path (the subscription token is often the path), both mocks
# had to be updated by hand and nothing failed when they were not - the fetcher
# test would have kept exercising the old behaviour and passing.
extract_fn() {
awk -v fn="$2" '
$0 ~ ("^export function " fn "\\(") { inside = 1 }
inside { print }
inside && /^};$/ { exit }
' "$1"
}
REAL_REDACT="$(extract_fn "$HOMEPROXY" redactUrl)"
MOCK_DRIFT=""
if [ -z "$REAL_REDACT" ]; then
fail "redactUrl could not be extracted from homeproxy-pro.uc"
else
for m in "$ROOT"/tests/ucode/mocks/*.uc; do
[ -f "$m" ] || continue
grep -q "^export function redactUrl(" "$m" || continue
if [ "$(extract_fn "$m" redactUrl)" != "$REAL_REDACT" ]; then
MOCK_DRIFT="$MOCK_DRIFT ${m##*/}"
fi
done
if [ -n "$MOCK_DRIFT" ]; then
fail "mock copies of redactUrl have drifted from homeproxy-pro.uc:$MOCK_DRIFT"
else
pass "every mock's redactUrl is identical to homeproxy-pro.uc's"
fi
fi
echo "== guard 36: the certificate upload buttons pass the handler's arguments =="
# The four Upload buttons wire L.bind(hp.uploadCertificate, hp, <label>, <name>)
# and LuCI calls onclick(ev, section_id): L.bind prepends the bound arguments,
# the event comes first. A handler that declares a leading parameter no caller
# passes shifts everything by one - filename became the event object, the
# staging path became /tmp/homeproxy_cert_[object Event].tmp, and neither the
# ACL nor certificate_write knew that path, so every certificate upload failed.
# guard 3 cannot see it (the last quoted argument of the call site was still the
# right UCI option name) and guard 23 only checks the bound receiver.
#
# The call site must therefore pass exactly the two arguments that occupy the
# handler's first two parameter slots: the button label and the UCI option
# name, each as a quoted literal. Nothing else may sit between them (a third
# argument, or a bare value shifted into the wrong slot, changes the staging
# path), and the handler must take exactly one parameter more than the call
# passes - the event LuCI adds in front.
CERT_HANDLER="$VIEWS/homeproxy-pro.js"
CERT_SITES=0
CERT_BAD=""
CERT_CALLS="$(mktemp)"
trap 'rm -f "$CERT_CALLS"' EXIT INT TERM
CERT_ARGS="$(sed -n 's/.*uploadCertificate(\([^)]*\)).*/\1/p' "$CERT_HANDLER" | head -1)"
CERT_NARGS="$(printf '%s' "$CERT_ARGS" | awk -F',' '{ print NF }')"
case "$CERT_NARGS" in
''|*[!0-9]*) CERT_NARGS="" ;;
esac
if [ -z "$CERT_ARGS" ]; then
fail "could not read the uploadCertificate parameter list from $CERT_HANDLER"
elif [ "$CERT_NARGS" != "3" ]; then
fail "uploadCertificate takes $CERT_NARGS parameters; the buttons pass the label and the filename, LuCI prepends the event, so it must take 3 (type, filename, ev)"
fi
grep -hoE "L\.bind\(hp\.uploadCertificate[^;]*" "$VIEWS"/view/homeproxy-pro/*.js > "$CERT_CALLS" 2>"/dev/null"
while IFS= read -r call; do
[ -n "$call" ] || continue
CERT_SITES=$((CERT_SITES + 1))
# The quoted literals of the call, in order.
lits="$(printf '%s' "$call" | sed -n "s/'[^']*'/&\n/gp" | tr -d "'" | tr '\n' '|')"
label="${lits%%|*}"
filename="$(printf '%s' "$lits" | sed 's/^[^|]*|//; s/|.*//')"
nlits="$(printf '%s' "$lits" | awk -F'|' '{ print NF - 1 }')"
if [ -z "$filename" ]; then
CERT_BAD="$CERT_BAD
$call passes no quoted filename"
elif [ "$nlits" -ne 2 ]; then
CERT_BAD="$CERT_BAD
$call passes $nlits quoted arguments (expected exactly the label and the filename)"
elif [ -z "$label" ]; then
CERT_BAD="$CERT_BAD
$call passes an empty button label"
fi
done < "$CERT_CALLS"
if [ "$CERT_SITES" -eq 0 ]; then
fail "no L.bind(hp.uploadCertificate ...) call site was found under $VIEWS"
elif [ -n "$CERT_BAD" ]; then
fail "the certificate upload call sites do not match uploadCertificate's signature:$CERT_BAD"
echo " L.bind prepends its arguments and LuCI passes the event first, so an unused"
echo " leading parameter makes 'filename' the event object and the staging path"
echo " leaves the ACL whitelist"
else
pass "$CERT_SITES certificate upload call sites pass a label and a filename for 3 parameters"
fi
echo
echo "== guard 37: the Makefile's conffiles block parses as the packaging path reads it =="
# guard 33 greps build-pkg.sh for the *shape* of its conffile handling; it never
# runs the other half of that contract. get_conffiles() is an awk state
# machine, and one drift in the Makefile markers - a `define` line that no
# longer matches the `^define Package/<pkg>/conffiles` anchor, an indented
# `endef`, entries moved above their header - makes it return nothing. Then
# `${#CONFFILES[@]}` is 0, build-pkg.sh takes its `else` branch and writes
# /etc/config/<app> alone, so the released apk/ipk stop protecting the eight
# resource files while guard 33 still passes. That is exactly the silent
# downgrade guard 33 was written to prevent, so the parse itself gets a check.
#
# Two independent measures, because comparing the state machine to itself would
# prove nothing: the same awk build-pkg.sh runs (get_conffiles), against a
# separately written exact-match counter and against the expected count. The
# count is deliberately a constant - changing which files a package protects is
# a deliberate packaging decision, and this is the line that makes it a
# deliberate edit here too.
MAKEFILE="$ROOT/Makefile"
MK_PKG_NAME="$(sed -n 's/^PKG_NAME:=//p' "$MAKEFILE" 2>"/dev/null" | head -1)"
# The same awk as .github/build-pkg.sh's get_conffiles(), byte for byte.
parse_conffiles() {
awk -v pkg="$1" '
$0 ~ "^define Package/"pkg"/conffiles" { flag=1; next }
flag && /^endef/ { flag=0; next }
flag && NF { print }
' "$MAKEFILE"
}
# The declared lines counted without the regex anchors: exact marker match plus
# a `/`-prefixed-line filter. If the awk regexes drift, the two disagree.
count_declared_conffiles() {
awk -v pkg="$1" '
$0 == "define Package/" pkg "/conffiles" { flag=1; next }
flag && $0 == "endef" { flag=0 }
flag && /^\// { n++ }
END { print n+0 }
' "$MAKEFILE"
}
EXPECTED_CONFFILES=9
CONF_PARSED="$(parse_conffiles "$MK_PKG_NAME")"
CONF_COUNT="$(printf '%s\n' "$CONF_PARSED" | grep -c .)"
CONF_DECLARED="$(count_declared_conffiles "$MK_PKG_NAME")"
if [ -z "$MK_PKG_NAME" ]; then
fail "PKG_NAME is not declared in the Makefile - the conffile block cannot be located"
elif [ -z "$CONF_PARSED" ]; then
fail "the get_conffiles() parse of Package/$MK_PKG_NAME/conffiles returned nothing - build-pkg.sh would ship /etc/config/<app> alone"
else
pass "parsed $CONF_COUNT conffiles out of Package/$MK_PKG_NAME/conffiles"
fi
if [ -n "$CONF_PARSED" ] && [ "$CONF_COUNT" = "$CONF_DECLARED" ]; then
pass "the awk parse sees every declared line ($CONF_COUNT of $CONF_DECLARED)"
else
fail "the get_conffiles() parse and the declared lines disagree: parsed ${CONF_COUNT:-0}, declared ${CONF_DECLARED:-0} - the anchor no longer matches the block"
fi
if [ "$CONF_COUNT" = "$EXPECTED_CONFFILES" ]; then
pass "Package/$MK_PKG_NAME/conffiles declares $EXPECTED_CONFFILES conffiles, the count guard 37 pins"
else
fail "Package/$MK_PKG_NAME/conffiles parses as $CONF_COUNT entries, guard 37 expects $EXPECTED_CONFFILES - if the packaging list really changed, update EXPECTED_CONFFILES in the same commit"
fi
# Each parsed path must sit between the block header and its endef. This is the
# part guard 33's grep cannot see: a path present elsewhere in the Makefile (a
# comment, another define) satisfies "the text appears" while the block parses
# empty or short.
CONF_DEFINE_LINE="$(grep -n "^define Package/$MK_PKG_NAME/conffiles\$" "$MAKEFILE" 2>"/dev/null" | head -1 | cut -d: -f1)"
CONF_ENDEF_LINE="$(awk -v start="${CONF_DEFINE_LINE:-0}" 'NR > start && $0 == "endef" { print NR; exit }' "$MAKEFILE")"
CONF_OUTSIDE=""
for f in $CONF_PARSED; do
# Looking for the path *inside* the range rather than taking its first
# occurrence: an identical line elsewhere in the Makefile would otherwise
# decide the answer.
CONF_IN_BLOCK="$(awk -v want="$f" -v lo="${CONF_DEFINE_LINE:-0}" -v hi="${CONF_ENDEF_LINE:-0}" '
NR > lo && NR < hi && $0 == want { found = 1 }
END { print found + 0 }
' "$MAKEFILE")"
[ "$CONF_IN_BLOCK" = "1" ] || CONF_OUTSIDE="$CONF_OUTSIDE $f"
done
if [ -n "$CONF_PARSED" ] && [ -z "$CONF_OUTSIDE" ]; then
pass "every parsed conffile sits inside the block (lines $CONF_DEFINE_LINE-$CONF_ENDEF_LINE)"
else
fail "these parsed conffiles are not inside Package/$MK_PKG_NAME/conffiles:$CONF_OUTSIDE"
fi
# A declared conffile the package does not ship protects nothing; the Makefile's
# own comment records that the previous list named four paths that exist
# nowhere in the tree.
CONF_MISSING=""
for f in $CONF_PARSED; do
case "$f" in
/*) [ -f "$ROOT/root$f" ] || CONF_MISSING="$CONF_MISSING $f" ;;
*) CONF_MISSING="$CONF_MISSING $f(not absolute)" ;;
esac
done
if [ -n "$CONF_PARSED" ] && [ -z "$CONF_MISSING" ]; then
pass "every declared conffile exists in the package payload (root/)"
else
fail "these declared conffiles are not shipped by the package, so the declaration protects nothing:$CONF_MISSING"
fi
echo "== guard 38: append_custom_dns emits the HTTPS/SVCB reject as its first DNS rule =="
# The proxy path emits this reject (append_proxy_dns line 102); the custom
# path used to rely on whatever the user wrote, so a rule that targets the
# same query_type could shadow the safety net. The fix prepends the same
# literal in append_custom_dns. Two checks keep that ordering:
#
# (a) the literal appears in BOTH append_proxy_dns and append_custom_dns;
# (b) the second occurrence (the one inside append_custom_dns) comes
# BEFORE the user-rules loop, so a user rule cannot shadow it.
#
# A regression that drops the custom-path literal, or moves it after the
# loop, would expose the user to Fake-IP bypass / HTTPS answer smuggling.
custom_dns_uc="$SCRIPTS/generator/dns.uc"
reject_count="$(grep -c 'query_type: *\[64, *65\]' "$custom_dns_uc")"
if [ "$reject_count" -eq 2 ]; then
pass "the HTTPS/SVCB reject appears in both append_proxy_dns and append_custom_dns"
else
fail "expected 2 occurrences of 'query_type: [64, 65]' in generator/dns.uc, found $reject_count"
fail "the custom path lost its built-in reject (see guard 38 in this file)"
fi
# (b): the custom-path reject is the SECOND occurrence (append_custom_dns
# is the second function defined). It must come before the user-rules
# loop, which is the first `for (let cfg in dm.dns.rules)` AFTER the
# `const builtin_dns_rules = []` that opens the custom-path rule block.
custom_reject_line="$(grep -n 'query_type: *\[64, *65\]' "$custom_dns_uc" | sed -n '2p' | cut -d: -f1)"
custom_const_line="$(awk 'NR > 1 && /const builtin_dns_rules = \[\]/ { print NR; exit }' "$custom_dns_uc")"
custom_loop_line="$(awk -v start="$custom_const_line" '
NR > start && /for \(let cfg in dm\.dns\.rules\)/ { print NR; exit }
' "$custom_dns_uc")"
if [ -n "$custom_reject_line" ] && [ -n "$custom_const_line" ] && [ -n "$custom_loop_line" ]; then
if [ "$custom_reject_line" -gt "$custom_const_line" ] && [ "$custom_reject_line" -lt "$custom_loop_line" ]; then
pass "append_custom_dns pushes the reject between the rules array init (line $custom_const_line) and the user-rules loop (line $custom_loop_line)"
else
fail "the reject in append_custom_dns (line $custom_reject_line) sits outside the"
fail "init (line $custom_const_line) -> loop (line $custom_loop_line) range; a"
fail "user rule could now shadow the HTTPS/SVCB reject"
fi
else
fail "could not locate both the reject and the user-rules loop in append_custom_dns"
fi
echo "== guard 39: sniffer_advanced_mode default stays at '0' =="
# The advanced-mode sniff profile
# (100ms / universal list) is gated behind a UCI opt-in so an upgrade
# does not change the sniffer behaviour. The default must remain '0';
# the generator falls back to '0' on a missing UCI value, and the
# package-shipped /etc/config/homeproxy-pro must not bump the default to
# '1' on a whim. Two checks:
#
# (a) the generator's fallback is '0';
# (b) the package-shipped UCI default is '0'.
if grep -q "sniffer_advanced_mode: dm.general.sniffer_advanced_mode || '0'" "$SCRIPTS/generator/context.uc"; then
pass "the generator falls back to '0' on a missing sniffer_advanced_mode"
else
fail "context.uc no longer falls back to '0' for sniffer_advanced_mode - the"
fail "sniff rule's 300ms / default-list profile is no longer the safe default"
fi
# The package default lives in root/etc/config/homeproxy-pro. It must be
# '0' for upgrades to be invisible; new installs also default to the
# same until the user opts in.
if grep -q "^ option sniffer_advanced_mode '0'" "$ROOT/root/etc/config/homeproxy-pro"; then
pass "the package-shipped /etc/config/homeproxy-pro keeps sniffer_advanced_mode '0'"
else
fail "the package default for sniffer_advanced_mode is no longer '0'; users who"
fail "upgraded without touching the option would silently switch to 100ms +"
fail "the universal sniffer list, which is a behaviour change"
fi
echo
echo "== guard 40: rule/routing rule action enum agrees across UI and generator =="
# The 'action' dropdown in client/common.js is the *user-visible* set the
# form lets the user pick. The generator's per-action gates (in dns.uc and
# route.uc) are the *what sing-box sees* set. When the UI ships an action
# the generator does not know about, the form serialises a value the
# generator then emits as `rule.action = '<unknown>'` - sing-box 1.14
# refuses the whole config with "unknown action". When the UI defines an
# action the generator does not handle, sing-box refuses the action with
# "unknown field". Both directions matter, both directions are checked.
#
# Reading order:
# UI dns_rule actions: extracted from common.js inside `if (is_dns) { ... }` of the
# action ListValue (the values that surface when is_dns=true)
# UI routing_rule actions: the values outside the is_dns branch
# generator dns_rule: all `'action' === '...'` literals in dns.uc's custom loop
# generator routing_rule: all `'action' === '...'` literals in route.uc's per-rule loop
ACTION_ENUM="$(python3 - "$ROOT" <<'PY' || ACTION_ENUM="__SCAN_FAILED__"
import re, sys, pathlib
root = pathlib.Path(sys.argv[1])
common = (root / 'htdocs/luci-static/resources/view/homeproxy-pro/client/common.js').read_text()
# Find the action ListValue block: from "ss.taboption('field_other', form.ListValue, 'action'"
# until 'so.default = 'route'' on the next non-conditional line. Within that
# block, is_dns and !is_dns branches control which values get exposed.
m = re.search(r"so = ss\.taboption\('field_other', form\.ListValue, 'action'.*?so\.default = 'route'",
common, re.DOTALL)
if not m:
print('__SCAN_FAILED__')
sys.exit()
block = m.group(0)
# Inside the if (is_dns) { ... } else { ... }, collect value('xxx', ...) names.
# The else branch is "routing_rule actions"; the if branch is "dns_rule actions".
is_dns_m = re.search(r"if \(is_dns\) \{(.*?)\} else \{(.*?)\}", block, re.DOTALL)
if not is_dns_m:
print('__SCAN_FAILED__')
sys.exit()
def actions(branch):
return set(re.findall(r"so\.value\('([^']+)'", branch))
dns_actions = actions(is_dns_m.group(1))
routing_actions = actions(is_dns_m.group(2))
dns_uc = (root / 'root/etc/homeproxy-pro/scripts/generator/dns.uc').read_text()
route_uc = (root / 'root/etc/homeproxy-pro/scripts/generator/route.uc').read_text()
def gates(text):
return set(re.findall(r"cfg\.action === '([^']+)'", text))
dns_gates = gates(dns_uc)
route_gates = gates(route_uc)
problems = []
ui_extra = dns_actions - dns_gates
if ui_extra:
problems.append(f'dns_rule UI exposes {sorted(ui_extra)} but generator/dns.uc does not gate on them')
gates_extra = dns_gates - dns_actions - {'route-options'} # generator-internal sentinel
# (Above: dns_gates may include 'route-options' from old guard; clean ignore.)
# A gate the UI does not offer is fine if the generator never emits it (matches),
# but we should still flag if it could be reached from UCI (impossible here because the ListValue is the only writer).
ui_extra_r = routing_actions - route_gates
if ui_extra_r:
problems.append(f'routing_rule UI exposes {sorted(ui_extra_r)} but generator/route.uc does not gate on them')
print('\n'.join(problems))
PY
)"
if [ "$ACTION_ENUM" = "__SCAN_FAILED__" ]; then
fail "the rule action enum scan could not run - fix the guard before trusting a pass"
elif [ -z "$ACTION_ENUM" ]; then
pass "every rule action in the UI dropdown is gated by the matching generator"
else
fail "the rule action enum drifted between the UI and the generator:"
printf ' %s\n' "$ACTION_ENUM"
fi
echo "== guard 41: experimental.cache_file is unconditional in attachExperimental() =="
# The 2026-09-29 review (P2 #3) found:
# attachExperimental() used to wrap the experimental.cache_file block in
# `if (routing_mode in ['bypass_mainland_china', 'custom'])`, leaving
# gfwlist / proxy_mainland_china / global with cold-start DNS on every
# reload. cache_file is a fresh-install no-op and the file is the only
# piece of state the generator writes outside /etc/config/homeproxy-pro, so
# gating it on routing_mode buys nothing and lost DNS cache for 3 modes.
# The fix removed the gate; this guard pins the unconditional emission so
# a future refactor (e.g. adding a 'cache_file.enabled: 0' opt-out) does
# not silently re-introduce the routing_mode gate.
AE_RESULT="$(python3 - "$ROOT/root/etc/homeproxy-pro/scripts/generator/common.uc" <<'PY' || echo __SCAN_FAILED__
import re, sys, pathlib
src = pathlib.Path(sys.argv[1]).read_text()
m = re.search(r'^export function attachExperimental\b.*?\n\};',
src, re.MULTILINE | re.DOTALL)
if not m:
print('__NO_FUNCTION__'); sys.exit()
body = m.group(0)
# Strip /* ... */ comments so a commented-out gate does not trip the check.
body_nc = re.sub(r'/\*.*?\*/', '', body, flags=re.DOTALL)
# Look for an `if (... routing_mode ...)` gate anywhere in the body.
gate = re.search(r'\bif\s*\([^)]*\brouting_mode\b', body_nc)
if gate:
print('GATED: ' + gate.group(0).strip()); sys.exit()
# Verify the unconditional shape: `config.experimental = { ... }` containing
# `cache_file:` must appear, both outside any `if (...)` block.
if not re.search(r'config\.experimental\s*=\s*\{', body_nc):
print('NO_EXPERIMENTAL_BLOCK'); sys.exit()
if 'cache_file:' not in body_nc:
print('NO_CACHE_FILE_FIELD'); sys.exit()
print('OK')
PY
)"
case "$AE_RESULT" in
__SCAN_FAILED__)
fail "the attachExperimental scan could not run - fix the guard before trusting a pass" ;;
__NO_FUNCTION__)
fail "attachExperimental() not found in generator/common.uc" ;;
NO_EXPERIMENTAL_BLOCK)
fail "attachExperimental() does not assign config.experimental (cache_file regression)" ;;
NO_CACHE_FILE_FIELD)
fail "attachExperimental() does not emit cache_file (cache_file regression)" ;;
GATED:*)
fail "attachExperimental() must not gate cache_file on routing_mode: ${AE_RESULT#GATED: }" ;;
OK)
pass "attachExperimental() emits cache_file unconditionally for every routing_mode" ;;
*)
fail "unexpected attachExperimental scan result: $AE_RESULT" ;;
esac
echo "== guard 42: NAPTR_BYPASS_SUFFIXES is the only domain_suffix qtype-35 source =="
# The 2026-09-29 review (P2 #4) found: the three
# NAPTR (qtype 35) bypass suffixes (r.10086.cn, 10086.cn, pub.3gppnetwork.org)
# used to be inlined into a push() call inside append_proxy_dns() and the
# comment that explained them was on the same line as the push. The fix
# extracted the list to a module-level constant NAPTR_BYPASS_SUFFIXES and
# moved the rationale to the constant's own comment block. This guard
# pins the actual invariant: a constant declaration exists, AND the
# qtype-35 DNS rule's domain_suffix field references that constant
# (not a hand-rolled literal array, which would re-inline the list).
DNS_UC="$ROOT/root/etc/homeproxy-pro/scripts/generator/dns.uc"
NAPTR_CONST="$(grep -nE '^(const|var)\s+NAPTR_BYPASS_SUFFIXES\s*=' "$DNS_UC" || true)"
NAPTR_USE="$(grep -nE '^\s*domain_suffix:\s*NAPTR_BYPASS_SUFFIXES\b' "$DNS_UC" || true)"
NAPTR_LITERAL_USE="$(grep -nE '^\s*domain_suffix:\s*\[' "$DNS_UC" || true)"
if [ -z "$NAPTR_CONST" ]; then
fail "NAPTR_BYPASS_SUFFIXES constant missing in generator/dns.uc"
elif [ -z "$NAPTR_USE" ]; then
fail "the qtype-35 DNS rule does not reference NAPTR_BYPASS_SUFFIXES; the constant is dead code"
elif [ -n "$NAPTR_LITERAL_USE" ]; then
fail "domain_suffix uses a literal array; route it through NAPTR_BYPASS_SUFFIXES instead:"
printf ' %s\n' "$NAPTR_LITERAL_USE"
else
pass "the qtype-35 DNS rule references NAPTR_BYPASS_SUFFIXES (no inline literal)"
fi
echo
echo "== guard 43: the rollback UCI snapshot matches the keys the intercept layer reads =="
# The 2026-09-29 review (P2 #1 / #2) found:
#
# reload_service rolls back by restoring the known-good sing-box file *and*
# replaying a UCI snapshot of the keys that steer the network layer - the
# second half exists because start_service reinstalls the nft / dnsmasq
# layer from CURRENT UCI, so without it a rollback would restore the sing-box
# bytes and leave the failing layer in place (review P1-3, 2026-09-28).
#
# That snapshot was a hand-maintained five-line list. It named four real
# keys out of the twenty-two the layer reads, and the fifth line was
# `infra.tun_address` - an option that exists nowhere; the real ones are
# `tun_addr4` / `tun_addr6` (config/loader.uc). So the line was always empty,
# the replay was a no-op for it, and tests/fixtures/runtime/trace.golden.txt
# recorded the empty line as the expected shape. A test that pins the format
# cannot notice that a key does not exist.
#
# The lesson is the one this file already states: compare the layers to each
# other instead of to a hand-written list. The read set below is extracted
# from the four files that actually build the intercept layer, and the two
# directions are checked:
#
# read - snapshot a key the layer reads was left out of the rollback
# snapshot - read a key nothing reads is in the rollback (the ghost-key
# case; the declared extras below are the only ones
# allowed)
#
# init.d/homeproxy-pro is deliberately NOT in the read set: its config_get calls
# serve the health gate (health_probe_*) and the cron (subscription.*), which
# do not steer the layer. Every key it reads that *does* steer the layer is
# also read by firewall_post.ut, so nothing is lost by excluding it.
# Pure shell on purpose: the first version of this guard ran the extraction
# from a python3 heredoc, and dash (which is what `sh` is on CI, and what
# tests/run.sh:176 invokes it with) did not recognise the heredoc inside the
# command substitution - the file failed to parse at all. grep / sed / awk
# are already this script's only tools, so nothing is lost.
SNAP_TMP="$(mktemp -d)"
trap 'rm -rf "$SNAP_TMP"' EXIT INT TERM
SNAP_POST="$ROOT/root/etc/homeproxy-pro/scripts/firewall_post.ut"
SNAP_PRE="$ROOT/root/etc/homeproxy-pro/scripts/firewall_pre.uc"
SNAP_NET="$ROOT/root/etc/homeproxy-pro/scripts/runtime/net.sh"
SNAP_DNS="$ROOT/root/etc/homeproxy-pro/scripts/runtime/dns.sh"
SNAP_SVC="$ROOT/root/etc/homeproxy-pro/scripts/runtime/service.sh"
for f in "$SNAP_POST" "$SNAP_PRE" "$SNAP_NET" "$SNAP_DNS" "$SNAP_SVC"; do
if [ ! -r "$f" ]; then
fail "guard 43 cannot read $f - fix the guard before trusting a pass"
break
fi
done
# The read set: uci.get(cfgname, '<section>', '<option>') in the two firewall
# scripts, plus the control_options array (walked through a loop variable, so
# its members are the only place those keys are spelled out), plus
# config_get / config_get_bool in the two runtime modules. `dhcp.<dnsmasq>`
# is another package's config and is deliberately not matched.
{
grep -hoE "uci\.get\(cfgname, *'[a-z_]+', *'[a-z_0-9]+'\)" "$SNAP_POST" "$SNAP_PRE" 2>/dev/null \
| sed -E "s/.*'([a-z_]+)', *'([a-z_0-9]+)'.*/\1.\2/"
sed -n '/const control_options = \[/,/\];/p' "$SNAP_POST" 2>/dev/null \
| grep -oE '"[a-z_0-9]+"' | tr -d '"' | sed 's/^/control./'
grep -hoE 'config_get[a-z_]* +[a-z_0-9]+ +"[a-z_]+" +"[a-z_0-9]+"' "$SNAP_NET" "$SNAP_DNS" 2>/dev/null \
| sed -E 's/.*"([a-z_]+)" +"([a-z_0-9]+)"/\1.\2/'
} 2>/dev/null | grep -E '^[a-z_]+\.[a-z_0-9]+$' | sort -u > "$SNAP_TMP/read"
# The snapshot the writer declares: the keys listed in the two shell string
# constants in runtime/service.sh. Both ends of a block need care: the first
# key sits on the opening `HP_INTERCEPT_*="` line and the last one shares its
# line with the closing quote, so neither is a line of its own. Dropping
# either would make the guard report a key the source does declare - which is
# exactly the false positive that appeared while this was being written.
awk '
/^HP_INTERCEPT_(SCALARS|LISTS)="/ {
inblock = 1
line = $0
sub(/^HP_INTERCEPT_(SCALARS|LISTS)="/, "", line)
gsub(/"/, "", line)
if (line ~ /^[a-z_]+\.[a-z_0-9]+$/) print line
next
}
inblock && /^[a-z_]+\.[a-z_0-9]+"?$/ { gsub(/"/, ""); print; next }
' "$SNAP_SVC" 2>/dev/null | sort -u > "$SNAP_TMP/snap"
# Declared extras: read by the generator (context.uc -> inbound.uc), never by
# a layer consumer. The TUN address only reaches the device through the
# generated file, so a rollback that left UCI describing the failed address
# would make the next reload regenerate the configuration that just failed.
printf '%s\n' infra.tun_addr4 infra.tun_addr6 | sort -u > "$SNAP_TMP/extras"
# Direction 1: a key the layer reads was left out of the rollback snapshot.
comm -23 "$SNAP_TMP/read" "$SNAP_TMP/snap" > "$SNAP_TMP/missing"
# Direction 2: a key nothing reads is in the snapshot - the tun_address case.
comm -23 "$SNAP_TMP/snap" "$SNAP_TMP/read" | comm -23 - "$SNAP_TMP/extras" > "$SNAP_TMP/ghost"
# Direction 3: a declared extra is no longer snapshotted. The extras are an
# allowlist, so without this they would silently become optional and dropping
# one from HP_INTERCEPT_SCALARS would go unnoticed.
comm -23 "$SNAP_TMP/extras" "$SNAP_TMP/snap" > "$SNAP_TMP/unused"
if [ ! -s "$SNAP_TMP/read" ]; then
fail "the intercept-layer read set came out empty - the extraction is broken, not the code"
elif [ -s "$SNAP_TMP/missing" ]; then
fail "these keys steer the intercept layer but are not in the rollback UCI snapshot:"
while IFS= read -r k; do printf ' %s\n' "$k"; done < "$SNAP_TMP/missing"
elif [ -s "$SNAP_TMP/ghost" ]; then
fail "the rollback UCI snapshot names keys no layer consumer reads (the tun_address case):"
while IFS= read -r k; do printf ' %s\n' "$k"; done < "$SNAP_TMP/ghost"
elif [ -s "$SNAP_TMP/unused" ]; then
fail "these keys are declared as snapshot-only but are not in HP_INTERCEPT_SCALARS:"
while IFS= read -r k; do printf ' %s\n' "$k"; done < "$SNAP_TMP/unused"
else
pass "the rollback UCI snapshot covers every intercept-layer key and names no unread key"
fi
rm -rf "$SNAP_TMP"
trap - EXIT INT TERM
echo
echo "== guard 45: the bootstrap resolver is derived, not configured =="
# main-dns is the user's DoH/DoT endpoint and the one server in the config
# whose address is a hostname, so it needs a domain_resolver before it can
# answer. It used to borrow default-dns - the WAN/ISP resolver - which makes
# the ISP the answerer of the single query that decides where every proxied
# name goes. It used to be a form field for that; it is derived now, from the
# China DNS server. Both halves are pinned:
# (a) the generator must read the China DNS server, never a separate option
# - a second DNS address next to the China one made no sense to read;
# (b) a bare IP is the only usable value, because the option also accepts
# 'wan' and a DoH/DoT URL, and a hostname cannot resolve a hostname.
#
# (b) is asserted on the *validator*, not on a hand-rolled regex. The guard
# used to require the two shape tests this function used to carry
# (/^[0-9]+(\.[0-9]+){3}$/ and /^[0-9a-fA-F:]+$/), and that is exactly what let
# a real bug through: the second one matches any hostname made only of hex
# digits and colons - cafe, face, abc, add, dead:beef - and the first one never
# range-checked the octets, so 999.999.999.999 passed too (measured on the
# device, 2026-10-01). The bootstrap server is emitted with no
# domain_resolver by design, so any of those is unsatisfiable and sing-box
# refuses to start at all:
# FATAL create service: initialize DNS server[0]: missing domain resolver
# for domain server address
# isValidCIDR() - the same validator the firewall path runs every address
# through - is the shared definition of "is this an address", and the ucode
# test tests/ucode/test_build_guards.uc pins the behaviour (bare v4/v6 in,
# 'wan' / a DoH URL / a hostname / an out-of-range address out).
if grep -q "const addr = ctx.china_dns_server;" "$SCRIPTS/generator/dns.uc"; then
pass "the bootstrap address is taken from the China DNS server"
else
fail "bootstrap_addr() no longer reads ctx.china_dns_server - if it reads a"
fail "separate option again, main-dns may fall back to the WAN resolver"
fi
if grep -q "isValidCIDR(addr, 4) || isValidCIDR(addr, 6)" "$SCRIPTS/generator/dns.uc"; then
pass "only a bare IP is accepted as the bootstrap address (via isValidCIDR)"
else
fail "bootstrap_addr() no longer rejects a non-IP value - a DoH URL or the"
fail "literal 'wan' is a hostname, and using one to resolve a hostname is a cycle"
fail "(it must go through isValidCIDR; a hand-rolled shape test accepted"
fail "'cafe' and 999.999.999.999, which made sing-box refuse to start)"
fi
if grep -qE "match\(addr, /\^\[0-9" "$SCRIPTS/generator/dns.uc"; then
fail "a hand-rolled IP shape test is back in bootstrap_addr() - use isValidCIDR"
else
pass "no hand-rolled IP shape test in bootstrap_addr()"
fi
# The option is gone from the form and from the shipped config; leaving either
# would mean a value nothing reads, which is how the confusing second field
# came to exist in the first place.
if grep -q "bootstrap_dns" "$ROOT/root/etc/config/homeproxy-pro"; then
fail "the package-shipped /etc/config/homeproxy-pro still carries a bootstrap_dns -"
fail "the generator derives it now, so this option is read by nothing"
else
pass "the package-shipped /etc/config/homeproxy-pro carries no bootstrap_dns"
fi
if grep -rq "bootstrap_dns" "$ROOT/htdocs/luci-static/resources/view/homeproxy-pro/"; then
fail "a view still renders a bootstrap_dns field - the value is derived from"
fail "the China DNS server, and a second DNS address only reads as a mistake"
else
pass "no view offers a bootstrap_dns field any more"
fi
echo
echo "== guard 46: IPv6 is only handled when mainland IPv6 is classifiable =="
# issue #4 / the china_ip6 defect share one root cause: every IPv6 decision
# was keyed off ipv6_support, a flag that says what the user *wants*, not
# whether the two halves of the split can agree on what "mainland" means.
# geoip-cn.srs and china_ip4.json are IPv4-only, so with IPv6 on a mainland
# destination reached over IPv6 matched no rule on either side and fell
# through to the default - the proxy. The firewall half was worse than
# useless in the empty-list case: nft accepts `elements = { }`, so the
# mainland v6 return matched nothing while the file looked fine.
#
# The invariants, all of which had to be introduced together:
# a) the template derives v6_handled from the list, not from the flag
# b) the v6 route rule exists, so the route half can classify
# c) the DNS cn-fallback match names the same v6 rule-set
# d) both halves ask the *same* question, so neither can name a tag the
# other never declared
# e) the degraded state is announced, not silent
# (a) The template must not branch on the raw flag anywhere. A single
# remaining `ipv6_support === '1'` gate is a v6 path that ignores whether the
# list can classify anything - the exact shape of the original defect.
#
# Scoped to template *tags* on purpose: the header legitimately tests the flag
# twice in plain code (defining v6_handled, and building the degraded-warning
# string), and a substring search over the whole file flags those too. What
# must not exist is a `{% ... %}` that branches on the raw flag.
leftover_v6_gates="$(grep -nE "\{%[^%]*ipv6_support[^%]*%\}" "$SCRIPTS/firewall_post.ut" || true)"
if [ -n "$leftover_v6_gates" ]; then
fail "firewall_post.ut still gates an IPv6 path on the raw ipv6_support flag:"
printf '%s\n' "$leftover_v6_gates" | sed 's/^/ /'
else
pass "no template tag in firewall_post.ut branches on the raw ipv6_support flag"
fi
# v6_handled must be the conjunction, not a rename of the flag.
if grep -q "v6_handled = (ipv6_support === '1') && cn_ipv6_ready" "$SCRIPTS/firewall_post.ut"; then
pass "v6_handled requires both the flag and a usable china_ip6 list"
else
fail "v6_handled is no longer (ipv6_support === '1') && cn_ipv6_ready - a v6 path"
fail "can be re-enabled without the list that makes it correct"
fi
# (b) The route half. Its absence is the silent proxy hop.
if grep -q "rule_set: 'china-ip6'" "$SCRIPTS/generator/route.uc"; then
pass "the route block carries a china-ip6 rule"
else
fail "no china-ip6 route rule - a mainland IPv6 destination matches nothing and"
fail "falls through to final, which is the proxy in bypass_mainland_china"
fi
# (c) The DNS half, or the answer for a mainland AAAA keeps coming from the
# proxy resolver even though the route side would now send it direct.
cn_fb_block="$(sed -n '/cn_fallback/,/^ }/p' "$SCRIPTS/generator/dns.uc" || true)"
if printf '%s' "$cn_fb_block" | grep -q "china-ip6"; then
pass "the cn-fallback response match covers china-ip6 as well as china-ip"
else
fail "cn-fallback still matches china-ip only; the DNS half of the IPv6 split"
fail "is not in agreement with the route half"
fi
# (d) One question, asked once, by the one layer allowed to ask it. The
# route rule and the DNS match must reference the same rule-set: if one of
# them decides the file is there and the other decides it is not, one names
# a tag the other never declared and sing-box rejects the whole config. The
# stat therefore belongs in the CLI's env (guard 27 forbids fs under
# generator/) and reaches both halves as one context field.
ctx_flag="$(grep -c 'china_ip6_ready' "$SCRIPTS/generator/context.uc")"
route_flag="$(grep -c 'ctx.china_ip6_ready' "$SCRIPTS/generator/route.uc")"
dns_flag="$(grep -c 'ctx.china_ip6_ready' "$SCRIPTS/generator/dns.uc")"
env_flag="$(grep -c 'china_ip6_ready' "$SCRIPTS/generate_client.uc")"
if [ "$ctx_flag" -ge 1 ] && [ "$route_flag" -ge 1 ] && [ "$dns_flag" -ge 1 ] && [ "$env_flag" -ge 1 ]; then
pass "one context field (china_ip6_ready) decides it for both halves, and the"
pass " CLI that is allowed to stat the file is the one that sets it"
else
fail "the route half ($route_flag), the DNS half ($dns_flag), the context ($ctx_flag)"
fail "and the CLI env ($env_flag) do not all read one china_ip6_ready flag - a"
fail "disagreement makes one of them name a rule-set the other never declared"
fi
# The default must be pessimistic. An env that forgot the field (the server
# path, a future caller) must not turn "no file" into "assume there is one".
if grep -q "china_ip6_ready: (env?.china_ip6_ready === true)" "$SCRIPTS/generator/context.uc"; then
pass "china_ip6_ready defaults to false when the caller did not resolve it"
else
fail "china_ip6_ready is not defaulting to false - a caller that omits it would"
fail "emit a rule naming a rule-set file that may not exist"
fi
# (e) The degraded state has to be visible without tcpdump: a log line on
# every start, and a rule comment that survives into `nft list ruleset`.
if grep -q "WARNING: IPv6 support is ON but" "$RUNTIME/service.sh"; then
pass "service.sh logs the unusable-china_ip6 state on every start"
else
fail "service.sh says nothing when IPv6 is on but china_ip6.txt is unusable -"
fail "the user is left with an unexplained, unproxied IPv6 path"
fi
if grep -q 'comment "!homeproxy-pro: WARNING china_ip6' "$SCRIPTS/firewall_post.ut"; then
pass "the degraded state is also carried as an nft rule comment, so"
pass " nft list ruleset shows it without reading the log"
else
fail "the degraded state is not announced in the ruleset itself"
fi
# (f) The degraded warning stays a single interpolated string.
#
# An earlier version of this guard banned `{% else %}` outright, on the
# strength of a render failure traced to "utpl cannot parse an else branch
# nested in the tproxy/tun chain blocks". That diagnosis was wrong and the
# ban with it: the failure only ever reproduced in stubbed copies of the
# template, never in the template itself - the else-form renders fine on the
# target, and else at top level, nested one level and nested two levels all
# render fine in isolation. The interpolation is kept because it is one
# string instead of three copies of a comment, not because else is banned.
#
# What is worth pinning is the thing that was actually verified: the warning
# has to be present on the jump rules, in a form that survives into
# `nft list ruleset`.
if grep -q "const v6_warn_comment" "$SCRIPTS/firewall_post.ut" &&
[ "$(grep -c "{{ v6_warn_comment }}" "$SCRIPTS/firewall_post.ut")" -eq 3 ]; then
pass "the degraded warning is one string interpolated into all three jump rules"
else
fail "v6_warn_comment is gone or no longer used by all three jump rules - the"
fail "degraded state has no in-ruleset marker again"
fi
echo
echo "== guard 47: fs.access() is only ever called with one argument =="
# Found on the target, not by reading: this ucode build's fs.access() answers
# in its one-argument form only. access(path) returns true for an existing
# path and null for a missing one; access(path, mode) returns null for both.
# A two-argument call therefore reports every file as missing, and the caller
# cannot tell that from "the file is not there" - the exact shape of a
# silently disabled feature. The IPv6 split shipped broken this way once:
# generate_client.uc asked access(path, 0) === 0, which is false even for a
# perfectly good china_ip6.json, so route/DNS stayed silent about mainland
# IPv6 while every log line said everything was fine.
#
# The one-argument call sites are correct and are not what this guards.
# Comment lines are filtered out the way guard 27 does it: this file, and the
# source it guards, both have to be able to *name* the bad form in prose.
bad_access="$(grep -rnE '\baccess\([^)]*,' --include='*.uc' "$SCRIPTS" 2>/dev/null |
grep -vE ':[0-9]+:[[:space:]]*(\*|/\*|//|#)' || true)"
if [ -z "$bad_access" ]; then
pass "no fs.access() call passes a second argument (the form that always says 'missing')"
else
fail "a two-argument fs.access() call reads as 'path does not exist' for every path:"
printf '%s\n' "$bad_access" | sed 's/^/ /'
fail " use the one-argument form, or lstat() (which the stderr-size check already uses)"
fi
# And the reason this is worth a guard rather than a comment: the failure is
# invisible. Both spellings compile, both run, and the wrong one produces a
# correct-looking config that is quietly missing a whole feature.
if grep -q "lstat(HP_DIR + '/resources/china_ip6.json')" "$SCRIPTS/generate_client.uc"; then
pass "the china-ip6 presence check goes through lstat(), which cannot be mis-called"
else
fail "the china-ip6 presence check no longer uses lstat()"
fi
echo
echo "== guard 48: the connection check probes the configured address family =="
# The button used to run `wget --spider` with no family, so its answer was
# about busybox wget's retry order rather than about the proxy. A target with
# seven AAAA records (Google) spends -T3 on each, so it blew the 3100 ms
# system() budget and the button said "failed" while the same request over
# IPv4 worked; Baidu passed only because its IPv6 goes out direct. Pin the two
# halves: the backend has to force the family, and the verdict has to say which
# one, or "passed" stays unfalsifiable.
if grep -qF "\${fetchBinary()} -\${(family === 'IPv6') ? '6' : '4'} -q -s -T3" "$RPC" &&
grep -qF "uci.get('homeproxy-pro', 'config', 'ipv6_support')" "$RPC"; then
pass "connection_check forces the address family from homeproxy-pro.config.ipv6_support"
else
fail "connection_check does not force an address family - the result is whatever"
fail "the fetcher happens to try first, which is how a working proxy reads as failed"
fi
if grep -qF "family: family" "$RPC"; then
pass "the probed family is returned so the view can report it"
else
fail "the response does not carry the family it probed, so the label cannot be"
fail "trusted - and the only way to get it in the view is to re-read UCI there"
fi
# The view must take the family from that response. An earlier revision called
# form.Map.formvalue(), which does not exist in this LuCI: the status page
# threw "m.formvalue is not a function" on a real router, and it only reached
# CI because the stubs had been given the same invented method. The stubs must
# keep modelling what LuCI actually provides, so a call the device does not
# have fails here instead of on someone's router.
if grep -qF "let fam = ret.family ?" "$VIEWS/view/homeproxy-pro/status.js"; then
pass "the view reports the family the backend returned"
else
fail "the view no longer shows which family was probed"
fi
if grep -rqE "\bm\.formvalue\(" "$VIEWS/view/homeproxy-pro/"; then
fail "a view calls form.Map.formvalue(), which this LuCI does not provide -"
fail " (formvalue lives on a section; the map has no such method)"
else
pass "no view calls the non-existent form.Map.formvalue()"
fi
echo "== guard 49: the i18n package name cannot drift from PKG_NAME =="
# luci.mk names the translation package from the *checkout directory*, not from
# PKG_NAME:
#
# LUCI_NAME?=$(notdir ${CURDIR})
# LUCI_BASENAME?=$(patsubst luci-$(LUCI_TYPE)-%,%,$(LUCI_NAME))
# define Package/luci-i18n-$(LUCI_BASENAME)-$(1)
#
# PKG_NAME is pinned to luci-app-homeproxy-pro here, so with the repository cloned
# under its own name (luci-app-homeproxy-pro, which is what every self-compiler
# gets) the feed build emitted luci-i18n-homeproxy-pro-zh-cn while the published
# apk was named by build-pkg.sh from PKG_NAME and came out
# luci-i18n-homeproxy-pro-zh-cn. Two names, one project, and the person self-building
# concludes the translation package is missing (issue #5).
#
# The check is that LUCI_BASENAME agrees with the PKG_NAME the release path uses,
# computed here from the Makefile the way luci.mk would - not by re-deriving it
# from this repository's own directory name, which is precisely the thing that
# was wrong and would make the assertion vacuous.
MAKEFILE="$ROOT/Makefile"
MK_PKG_NAME="$(sed -n 's/^PKG_NAME:=//p' "$MAKEFILE" 2>"/dev/null" | head -1)"
MK_LUCI_BASENAME="$(sed -n 's/^LUCI_BASENAME:=//p' "$MAKEFILE" 2>"/dev/null" | head -1)"
if [ -z "$MK_PKG_NAME" ]; then
fail "PKG_NAME is not declared in the Makefile - nothing to keep the i18n name tied to"
elif [ -z "$MK_LUCI_BASENAME" ]; then
fail "LUCI_BASENAME is not pinned, so the i18n package name follows the checkout"
fail " directory: cloning as luci-app-homeproxy-pro yields luci-i18n-homeproxy-pro-zh-cn"
fail " while the release path names it luci-i18n-homeproxy-pro-zh-cn (issue #5)"
else
# luci.mk strips the "luci-<type>-" prefix; type is the 2nd dash-field.
mk_type="$(printf '%s' "$MK_PKG_NAME" | awk -F- '{print $2}')"
mk_expected_basename="$(printf '%s' "$MK_PKG_NAME" | sed -n "s/^luci-${mk_type}-//p")"
if [ "$MK_LUCI_BASENAME" = "$mk_expected_basename" ]; then
pass "LUCI_BASENAME matches PKG_NAME, so the i18n package is named the same"
pass " in the feed build and in the release (luci-i18n-${MK_LUCI_BASENAME}-zh-cn)"
else
fail "LUCI_BASENAME is '$MK_LUCI_BASENAME' but PKG_NAME '$MK_PKG_NAME' implies"
fail " '$mk_expected_basename' - the self-built i18n package will not match the release"
fi
fi
echo
echo "== guard 50: the rule-set path policy is identical in both layers =="
# The rule-set form's `path` and `initial_path` were bare form.Value options
# with datatype='file', which in LuCI is `file() { return true; }` - so any
# string saved, and the generator was the first thing to object. Adding a
# frontend validator means the same class of bug guard 29 pins for
# certificates can now happen here instead: two lists, one per language, and
# nothing able to see across the JS/ucode boundary.
#
# The failure is asymmetric on purpose. A path the UI rejects but the backend
# accepts is a UI bug; a path the UI accepts but the backend drops is the
# dangerous one - the field vanishes from the generated configuration, the
# rule-set silently reverts to a blocking first download, and the user is left
# with a configuration that looks configured. The generator now also refuses
# loudly, but the UI check is what stops it at the field they are editing.
#
# Compared in BOTH directions, and also against the two places the policy has
# to stay coherent beyond the lists themselves: the message the validator
# shows, and the path the form offers as its placeholder / datalist entry. A
# placeholder naming a directory the validator refuses is the bug this guard
# exists for, wearing a different hat.
RULE_ROOTS="$(python3 - "$SCRIPTS/homeproxy-pro.uc" "$VIEWS/homeproxy-pro.js" "$VIEWS" <<'PY'
import re, sys
def roots(path, name, label):
text = open(path, encoding='utf-8').read()
m = re.search(r'\b%s\s*=\s*\[([^\]]*)\]' % name, text)
if not m:
return None, '%s: could not find %s' % (label, name)
return re.findall(r"'([^']+)'", m.group(1)), None
problems = []
backend, err = roots(sys.argv[1], 'RULE_PATH_ROOTS', 'homeproxy-pro.uc')
problems += [err] if err else []
frontend, err = roots(sys.argv[2], 'HP_RULE_PATH_ROOTS', 'homeproxy-pro.js')
problems += [err] if err else []
if not problems:
if backend != frontend:
problems.append('homeproxy-pro.uc %s != homeproxy-pro.js %s' % (backend, frontend))
elif not backend:
problems.append('RULE_PATH_ROOTS is empty - the rule-set gate would accept nothing')
else:
# Every root has to exist in the packaged tree, or the form points the
# user at a directory the package never creates. uci-defaults creates
# the archive at install time and hp_prepare_ruleset_dir() before every
# generation, so the literal root string has to be the one those two
# agree on - a rename of the archive in only one of the three places
# would leave the user with an unsavable form.
for src, label in ((sys.argv[1], 'homeproxy-pro.uc'), (sys.argv[2], 'homeproxy-pro.js')):
text = open(src, encoding='utf-8').read()
for r in backend:
if r not in text:
problems.append('%s does not mention the root %s' % (label, r))
# The offered default must be inside a declared root, and it must be
# the same string the frontend exposes as hp.rule_path_default.
js = open(sys.argv[2], encoding='utf-8').read()
m = re.search(r"\bHP_RULE_PATH_DEFAULT\s*=\s*'([^']+)'", js)
if not m:
problems.append('homeproxy-pro.js: could not find HP_RULE_PATH_DEFAULT')
elif not any(m.group(1).startswith(r) and len(m.group(1)) > len(r) for r in backend):
problems.append('HP_RULE_PATH_DEFAULT %r is not inside any RULE_PATH_ROOTS entry %s'
% (m.group(1), backend))
if not re.search(r'\brule_path_default:\s*HP_RULE_PATH_DEFAULT\b', js):
problems.append('homeproxy-pro.js: HP_RULE_PATH_DEFAULT is not exported as rule_path_default,'
' so the view cannot quote it and would repeat the literal')
# The validator the rule-set form binds must be the one that closes
# over this list. A form option pointing at the certificate validator
# would accept /etc/ssl/ and refuse the archive.
for view in ('client/subscription.js',):
body = open('%s/view/homeproxy-pro/%s' % (sys.argv[3], view), encoding='utf-8').read()
for field in ('path', 'initial_path'):
m = re.search(r"option\(form\.Value,\s*'%s'.*?(?=so = |\Z)" % field, body, re.S)
if not m:
problems.append('%s: no %s option found' % (view, field))
elif 'hp.validateRuleSetPath' not in m.group(0):
problems.append('%s: the %s option does not bind hp.validateRuleSetPath'
% (view, field))
print('\n'.join(problems))
PY
)" || RULE_ROOTS="__SCAN_FAILED__"
if [ "$RULE_ROOTS" = "__SCAN_FAILED__" ]; then
fail "the rule-set-roots scan could not run - fix the guard before trusting a pass"
elif [ -z "$RULE_ROOTS" ]; then
pass "the frontend and backend rule-set path roots agree, and the offered default is inside them"
else
fail "the rule-set path policy is not coherent across the layers:"
printf ' %s\n' "$RULE_ROOTS"
fi
echo
echo "== guard 51: the format probe stays a probe, and the form stops fighting it =="
# issue #7 batch 1 turned a comment into a mechanism: generation reads the first
# bytes of each rule-set file and declares `format` from that, and a bare
# `update_interval` is normalised before sing-box parses it as a Go duration.
#
# Every part of that is a decision that can be undone by a well-meaning edit,
# and each reversion reproduces a failure that is invisible until a router
# refuses to start:
#
# (a) the disk half of the probe moving under generator/ - the purity
# invariant guard 27 protects, but at a different boundary than the one
# it watches (guard 27 bans the fs/ubus IMPORT; this bans the CALL, so
# a generator could reach the disk through a helper in homeproxy-pro.uc and
# still pass guard 27)
# (b) the probe reimplemented on isBinary() - which answers "is this text?",
# a question correlated with the format rather than equal to it (see the
# comment on ruleSetFormatFromBytes for why that correlation is luck)
# (c) update_interval passing through raw again, which is the measured
# `time: missing unit in duration "3600"` hard failure
# (d) the form's `format` option growing a default back, which is what made
# "add a local rule-set, pick my .json, forget Format" produce a
# configuration that parsed JSON as a compiled .srs
# (e) the update_interval placeholder showing a unit Go cannot read, which
# is how `1d` got there in the first place
PROBE="$(python3 - "$SCRIPTS" "$VIEWS" <<'PY'
import pathlib, re, sys
scripts, views = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2])
ruleset_uc = (scripts / 'generator' / 'ruleset.uc').read_text(encoding='utf-8')
homeproxy_uc = (scripts / 'homeproxy-pro.uc').read_text(encoding='utf-8')
client_uc = (scripts / 'generate_client.uc').read_text(encoding='utf-8')
sub_js = (views / 'view' / 'homeproxy-pro' / 'client' / 'subscription.js').read_text(encoding='utf-8')
problems = []
# (a) Only the process boundary may touch the disk for a probe.
for f in sorted((scripts / 'generator').glob('*.uc')):
body = f.read_text(encoding='utf-8')
if re.search(r'\bprobeRuleSetFile\s*\(', re.sub(r'/\*[\s\S]*?\*/', '', body)):
problems.append('%s calls probeRuleSetFile(); only the CLI may read a rule-set file'
% f.name)
if not re.search(r'\bprobeRuleSetFile\s*\(', re.sub(r'/\*[\s\S]*?\*/', '', client_uc)):
problems.append('generate_client.uc no longer probes the rule-set files, so ruleset_formats '
'is never populated and the correction in ruleset.uc can never fire')
# (b) The magic is compared byte by byte, and isBinary() is not the test.
m = re.search(r'export function ruleSetFormatFromBytes\([^)]*\)\s*\{(.*?)\n\};', homeproxy_uc, re.S)
if not m:
problems.append('homeproxy-pro.uc: ruleSetFormatFromBytes() is gone; nothing decides the format')
else:
body = re.sub(r'/\*[\s\S]*?\*/', '', m.group(1))
for byte in ('0x53', '0x52'):
if byte not in body:
problems.append('ruleSetFormatFromBytes() no longer compares the SRS magic byte %s' % byte)
if re.search(r'\bisBinary\s*\(', body):
problems.append('ruleSetFormatFromBytes() calls isBinary(); that answers "is this text?", '
'which is correlated with the rule-set format rather than equal to it')
# (c) update_interval is normalised, not forwarded.
if 'strToTime(cfg.update_interval)' not in ruleset_uc:
problems.append("ruleset.uc no longer runs update_interval through strToTime(); a bare number "
"goes to sing-box as `missing unit in duration`")
if re.search(r'update_interval:\s*cfg\.update_interval\b', ruleset_uc):
problems.append('ruleset.uc forwards update_interval raw again')
# (d)/(e) The rule-set form must not re-introduce the two UI defects.
def option_block(src, name):
m = re.search(r"option\(form\.\w+,\s*'%s'.*?(?=\n\tso = |\n\t/\* Rule set settings end)" % name,
src, re.S)
return m.group(0) if m else None
fmt = option_block(sub_js, 'format')
if fmt is None:
problems.append('subscription.js: the format option is gone')
else:
if re.search(r'\bso\.default\s*=', fmt):
problems.append("the rule-set form gives `format` a default again; an explicit value beats "
"sing-box's extension inference, so every local rule-set ships a format that "
"may contradict its own file")
if not re.search(r'\bso\.rmempty\s*=\s*true', fmt):
problems.append("the rule-set form's `format` is not rmempty; nothing can be left unset, so "
"the generator's read-the-bytes correction is the only thing standing between "
"a user and a rejected configuration")
iv = option_block(sub_js, 'update_interval')
if iv is None:
problems.append('subscription.js: the update_interval option is gone')
else:
if not re.search(r'\bso\.validate\s*=', iv):
problems.append('update_interval has no validator, so a unit Go cannot read reaches sing-box '
'and rejects the whole configuration at apply time')
m = re.search(r"so\.placeholder\s*=\s*'([^']*)'", iv)
if not m:
problems.append('update_interval has no placeholder')
elif not re.fullmatch(r'(\d+(\.\d+)?(ns|us|µs|ms|s|m|h))+', m.group(1)):
problems.append("the update_interval placeholder %r is not a Go duration; Go's units are "
"ns/us/ms/s/m/h, and a placeholder is the value users copy verbatim"
% m.group(1))
print('\n'.join(problems))
PY
)" || PROBE="__SCAN_FAILED__"
if [ "$PROBE" = "__SCAN_FAILED__" ]; then
fail "the format-probe scan could not run - fix the guard before trusting a pass"
elif [ -z "$PROBE" ]; then
pass "the format probe reads only at the process boundary, decides on the magic,"
pass " normalises update_interval, and the form neither defaults nor misleads"
else
fail "the format probe / duration normalisation has been undone:"
printf ' %s\n' "$PROBE"
fi
echo
echo "== guard 52: the China split is local, and both halves read one list =="
# The split that keeps mainland traffic direct used to be fed by two remote
# rule-sets downloaded from SagerNet's repositories: geoip-cn on the route
# side, geosite-cn on the DNS side. Three things were wrong with that and all
# three are silent when they come back:
#
# - a cold start had to fetch them before the inbounds bind, through the
# node, so the proxy's own start depended on the CDN being reachable
# through the proxy;
# - the kernel's half (homeproxy_mainland_addr_v4, from china_ip4.txt) and
# the resolver's half came from different files, and route.uc once carried
# a corrective rule plus a long comment because they disagreed;
# - every day of that, the resource lists behind the kernel's half were only
# refreshed if the user happened to have subscription auto-update on.
#
# They are local files now, each generated from a list the resource updater
# maintains, and the firewall renders its nft set from those same lists. This
# guard pins the arrangement rather than the feature it replaced: the tags the
# two halves name have to be the same tags route.uc declares, they have to be
# declared local, and nothing may reintroduce a remote download for them.
CN_LOCAL="$(python3 - "$ROOT" "$SCRIPTS" <<'PY'
import pathlib, re, sys
root, scripts = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2])
route_uc = (scripts / 'generator/route.uc').read_text(encoding='utf-8')
dns_uc = (scripts / 'generator/dns.uc').read_text(encoding='utf-8')
common_uc = (scripts / 'generator/common.uc').read_text(encoding='utf-8')
client_uc = (scripts / 'generate_client.uc').read_text(encoding='utf-8')
service_sh = (scripts / 'runtime/service.sh').read_text(encoding='utf-8')
update_sh = (scripts / 'update_resources.sh').read_text(encoding='utf-8')
fw_ut = (scripts / 'firewall_post.ut').read_text(encoding='utf-8')
problems = []
def strip_comments(src):
src = re.sub(r'/\*[\s\S]*?\*/', '', src)
return '\n'.join(l for l in src.split('\n') if not l.lstrip().startswith(('#', '//', '*')))
route, dns = strip_comments(route_uc), strip_comments(dns_uc)
# (a) None of the three may be a remote rule-set again. A `type: 'remote'`
# in the built-in block brings back both the cold-start download and the
# second copy of the data.
decl = re.search(r'if \(declaresBuiltinRuleSets.*?\n\t\}', route, re.S)
if not decl:
problems.append('route.uc no longer declares the built-in rule-sets behind '
'declaresBuiltinRuleSets(); the China split would have no data source')
else:
block = decl.group(0)
for tag in ('china-ip', 'china-ip6', 'china-domain'):
if "tag: '%s'" % tag not in block:
problems.append("route.uc no longer declares '%s'; the rule that matches it "
"would fail the whole configuration with rule-set not found" % tag)
if "type: 'remote'" in block:
problems.append("route.uc declares a built-in as type: 'remote' again; a cold start "
"would have to download it before the inbounds bind")
# (b) The two halves must name the same tags. This is the part that actually
# drifted before: the route rule named geoip-cn and the DNS rule named
# geosite-cn, and the fix was a second rule rather than one list.
if re.search(r"rule_set:\s*'geoip-cn'", route + dns):
problems.append("a China rule names 'geoip-cn' again; the address list is china-ip, "
"generated from the same china_ip4.txt the nft set is rendered from")
if re.search(r"rule_set:\s*'geosite-cn'", route + dns):
problems.append("a China rule names 'geosite-cn' again; the domain list is china-domain, "
"generated from china_list.txt")
if "rule_set: 'china-domain'" not in dns:
problems.append('dns.uc no longer routes the China domain list; those domains would fall '
'through to the mode default and resolve through the proxy')
# (c) The fallback tags have to be declared under the same condition they are
# referenced, or sing-box rejects the config with an undeclared tag.
for name, src, tag in (('route', route, 'china-ip6'), ('dns', dns, 'china-ip6')):
if re.search(r"rule_set:\s*'china-ip6'", src) and 'china_ip6_ready' not in src:
problems.append('%s names china-ip6 without gating on china_ip6_ready; the declaration '
'is conditional, so this is an undeclared tag' % name)
# (d) Both halves of each list have to be generated by something that runs.
# domain_ruleset.uc is the newest of the three and the only one with no
# long history pointing at it, so it is the one that can quietly go
# missing and leave china-domain.json absent - which degrades the DNS
# split with nothing in the log.
for name, src in (('runtime/service.sh', service_sh), ('update_resources.sh', update_sh)):
if 'domain_ruleset.uc' not in src:
problems.append('%s no longer invokes domain_ruleset.uc; china-domain.json is only '
'produced there, and a missing file silently drops the DNS half of '
'the split' % name)
if 'china_ip_ruleset.uc' not in service_sh:
problems.append('runtime/service.sh no longer invokes china_ip_ruleset.uc; a cold install '
'would have no china_ip4.json and the route half would be missing too')
# (e) The kernel's half has to keep coming from the same .txt the generator's
# half is made from. This is the pair that disagreed once.
if "resources_dir + '/china_ip4.txt'" not in fw_ut:
problems.append('firewall_post.ut no longer renders the mainland set from china_ip4.txt; '
'the kernel and the resolver would decide "mainland" from two lists again')
print('\n'.join(problems))
PY
)" || CN_LOCAL="__SCAN_FAILED__"
if [ "$CN_LOCAL" = "__SCAN_FAILED__" ]; then
fail "the China-split scan could not run - fix the guard before trusting a pass"
elif [ -z "$CN_LOCAL" ]; then
pass "the China split is three local files, the two halves name the same tags, and"
pass " each half is generated by something that runs on start and on update"
else
fail "the China split has drifted off one list:"
printf ' %s\n' "$CN_LOCAL"
fi
echo
echo "== guard 53: every dm.general field the generators read is one the Loader lists =="
# config.general in the Loader is an EXPLICIT key list, not a pass-through of
# the UCI section. A field read as `dm.general.foo` that is not in that list
# does not read as "absent" - it reads as "never set", and a `|| '0'` fallback
# in context.uc then makes it indistinguishable from a user who deliberately
# left the option off.
#
# That is not a hypothetical. ruleset_safe_start was added to
# /etc/config/homeproxy-pro and read in context.uc (removed in r41), and nothing said the key was
# missing: the configuration generated cleanly, `sing-box check` passed, every
# test that did not look for the feature's effect was green, and the feature
# was simply never on. Only the e2e case that asserts an initial_path exists
# could see it, and it read as a generator bug.
#
# So this compares the two lists mechanically. The read set is derived from
# what the code actually says, not from a hand-kept list, so a new option
# cannot be added to one side and forgotten on the other.
GENERAL_KEYS="$(python3 - "$SCRIPTS" <<'PY'
import pathlib, re, sys
scripts = pathlib.Path(sys.argv[1])
loader = (scripts / 'config/loader.uc').read_text(encoding='utf-8')
# What the Loader declares on config.general.
m = re.search(r'config\.general\s*=\s*\{(.*?)\n\t\t\};', loader, re.S)
if not m:
print('config/loader.uc: could not find the config.general object literal')
raise SystemExit(0)
declared = set(re.findall(r'^\s*([a-z][a-z0-9_]*)\s*:', m.group(1), re.M))
# What the consumers read off dm.general. Comments stripped first: the prose
# above ruleset_safe_start names dm.general.ruleset_safe_start on purpose, and a
# substring search would read that as a use.
def strip(src):
src = re.sub(r'/\*[\s\S]*?\*/', '', src)
return '\n'.join(l for l in src.split('\n') if not l.lstrip().startswith(('#', '//')))
readers = ['generator/context.uc', 'generate_client.uc']
readers += [str(p.relative_to(scripts)) for p in sorted((scripts / 'generator').glob('*.uc'))]
problems = []
for rel in dict.fromkeys(readers):
f = scripts / rel
if not f.is_file():
continue
src = strip(f.read_text(encoding='utf-8'))
for key in sorted(set(re.findall(r'\bdm\.general\.([a-z][a-z0-9_]*)', src))):
if key not in declared:
problems.append('%s reads dm.general.%s, which the Loader does not declare - '
'the value is always null there, so a `||` fallback makes the '
'option indistinguishable from one the user never set' % (rel, key))
print('\n'.join(problems))
PY
)" || GENERAL_KEYS="__SCAN_FAILED__"
if [ "$GENERAL_KEYS" = "__SCAN_FAILED__" ]; then
fail "the general-key scan could not run - fix the guard before trusting a pass"
elif [ -z "$GENERAL_KEYS" ]; then
pass "every dm.general field the generators and the CLI read is declared by the Loader"
else
fail "the Loader and its consumers disagree about the config section's keys:"
printf ' %s\n' "$GENERAL_KEYS"
fi
echo
echo "== guard 54: the fetch layer uses uclient-fetch, and nothing reintroduces wget =="
# `wget` is whichever implementation the firmware buildroot compiled, and the
# two share almost no options beyond -O. Every fetch in this package used to
# shell out to it with GNU-only flags (-nv --user-agent --timeout= --spider),
# which exits 2 with "unrecognized option" on a busybox-wget router BEFORE any
# request is made - so subscriptions never fetched, resource lists never
# updated, and the connectivity check reported a failed proxy. Reported as
# issue #6.
#
# What let it survive several releases is the interesting part: the suite had a
# guard for the exact shape (the fetch must not fail with a usage error) and it
# only ever ran against the fetcher the host already had - GNU wget on CI, GNU
# wget on the maintainer device. So this guard is not only "no wget" but also
# "and the test that would have caught it runs against a pinned stub".
#
# uclient-fetch is the fetcher OpenWrt itself drives (opkg, sysupgrade, uci) and
# its option set is fixed by the applet rather than by the buildroot, which is
# the only reason one command line can be correct on every target.
FETCH_LAYER="$(python3 - "$ROOT" "$SCRIPTS" "$RPC" <<'PY'
import pathlib
import re
import sys
root, scripts, rpc = (pathlib.Path(p) for p in sys.argv[1:4])
problems = []
def code_only(path):
"""Source with block comments and comment-only lines removed.
Comments are excluded on purpose: several of them explain WHY wget is not
used, and a guard that flagged its own documentation would be deleted
rather than obeyed.
"""
src = path.read_text(encoding="utf-8")
src = re.sub(r"/\*[\s\S]*?\*/", "", src)
lines = [l for l in src.split("\n") if not l.lstrip().startswith(("#", "//"))]
return "\n".join(lines)
# 1. No wget anywhere it could actually be invoked.
wget_call = re.compile(r"(?<![\w/-])wget(?![\w-])")
targets = [p for p in scripts.rglob("*") if p.is_file() and p.suffix in (".uc", ".sh")]
targets.append(rpc)
for path in targets:
for line in code_only(path).split("\n"):
if wget_call.search(line):
problems.append("%s still invokes wget: %s" % (path.name, line.strip()[:90]))
# 2. The fetcher has to be a declared dependency, or a firmware without it in
# base leaves every fetch broken in a much quieter way.
if "+uclient-fetch" not in (root / "Makefile").read_text(encoding="utf-8"):
problems.append("the Makefile does not declare +uclient-fetch; the fetch layer "
"depends on a package it does not require")
# 3. The guard that would have caught this has to run against a stub whose
# accepted option set is pinned, not against the host own fetcher.
runner = (root / "tests/ucode/run.sh").read_text(encoding="utf-8")
stub = root / "tests/fixtures/uclient-fetch-stub"
if "tests/fixtures/uclient-fetch-stub" not in runner:
problems.append("tests/ucode/run.sh no longer points the fetch guard at the pinned stub")
if not stub.is_file():
problems.append("the pinned fetch stub is missing from the package payload")
elif "unrecognized option" not in stub.read_text(encoding="utf-8"):
# A permissive stub cannot catch the regression, which is the whole point:
# it has to reject what it does not recognise, the way the applet does.
problems.append("the fetch stub does not reject unknown options, so it cannot "
"catch an option nobody verified")
print("\n".join(problems))
PY
)" || FETCH_LAYER="__SCAN_FAILED__"
if [ "$FETCH_LAYER" = "__SCAN_FAILED__" ]; then
fail "the fetch-layer scan could not run - fix the guard before trusting a pass"
elif [ -z "$FETCH_LAYER" ]; then
pass "no wget in the fetch layer, uclient-fetch is a declared dependency, and the"
pass " guard that catches a bad option runs against a pinned stub"
else
fail "the fetch layer is not pinned to one fetcher:"
printf ' %s\n' "$FETCH_LAYER"
fi
# guard 55: every join() call passes the separator first.
#
# ucode's join(sep, list) does not validate its arguments. Swapped, it
# returns null rather than raising, and null + '\n' is the string "null\n" -
# a non-empty file naming nothing. That shipped in r41: the node-address
# export wrote "null", dnsmasq rendered nftset=/.null/... and the node
# bypass silently did nothing while every existence check stayed green.
#
# This is cheap to pin because every call is a literal, so an argument that
# is a list literal or a call returning one is the tell.
join_bad="$(python3 - "$SCRIPTS" "$RPC" <<'PY_JOIN'
import re, sys, pathlib
def strip_comments(src):
src = re.sub(r"/\*.*?\*/", " ", src, flags=re.S)
return re.sub(r"//[^\n]*", " ", src)
def top_level_first(args):
"""First argument, split on commas that are not inside quotes or parens -
the separator itself is routinely ', '."""
depth, j = 0, 0
while j < len(args):
c = args[j]
if c in "'\"":
k = j + 1
while k < len(args) and args[k] != c:
k += 1
j = k + 1; continue
if c in "([{":
depth += 1
elif c in ")]}":
depth -= 1
elif c == "," and depth == 0:
return args[:j].strip()
j += 1
return args.strip()
def join_args(src, i):
depth, j, out = 0, i, []
while j < len(src):
c = src[j]
if c in "'\"":
k = j + 1
while k < len(src) and src[k] != c:
k += 1
out.append(src[j:k + 1]); j = k + 1; continue
if c == "(":
depth += 1
if depth == 1:
j += 1; continue
elif c == ")":
depth -= 1
if depth == 0:
return "".join(out), j
out.append(c); j += 1
return None, j
bad = []
for root in sys.argv[1:]:
p = pathlib.Path(root)
files = ([p] if p.is_file()
else sorted(p.rglob("*.uc")) + sorted(p.rglob("*.ut")))
for f in files:
src = strip_comments(f.read_text(errors="replace"))
for m in re.finditer(r"\bjoin\s*\(", src):
args, _ = join_args(src, m.end() - 1)
if args is None:
continue
first = top_level_first(args)
# A separator is a string literal. Anything else in that slot -
# a call, a subscript, a bare identifier - is the swapped form,
# and ucode answers it with null instead of raising.
if not re.fullmatch(r"'[^']*'|\"[^\"]*\"", first):
bad.append("%s: join(%s, ...)" % (f.name, first))
print("\n".join(sorted(set(bad))))
PY_JOIN
)"
if [ -n "$join_bad" ]; then
fail "a join() call does not pass a string literal first (ucode takes join(sep, list), and"
fail "swapped it returns null silently instead of raising):"
printf ' %s\n' "$join_bad"
else
pass "every join() call passes a string-literal separator first; swapped arguments would"
pass " have returned null silently and shipped an empty-of-meaning export"
fi
# guard 56: no `{% set %}` tag assigns the result of a function call.
#
# utpl's set tag takes a plain expression and rejects a call outright:
# `{% set x = f(); %}` is a parse error. The damage is disproportionate to
# the typo, because a parse error kills the whole render - firewall_post.ut
# produces a zero-byte fw4_post.nft, fw4 has nothing new to load, and the
# health gate quietly keeps the previous ruleset. Nothing looks broken: the
# service is up, the proxy works, and the template change is simply absent.
# r41 shipped such a line and never rendered once; r43 fixed it.
#
# So the rule is mechanical and worth pinning: inside a set tag, no `(`.
set_call="$(python3 - "$SCRIPTS" <<'PY_SET'
import re, sys, pathlib
bad = []
for p in sorted(pathlib.Path(sys.argv[1]).rglob("*.ut")):
src = p.read_text(errors="replace")
# Comments quote the mistake on purpose; only real tags count.
src = re.sub(r"/\*.*?\*/", " ", src, flags=re.S)
for m in re.finditer(r"\{%-?\s*set\b([^%]*?)-?%\}", src, flags=re.S):
rhs = m.group(1)
# A call is any identifier immediately followed by "(".
if re.search(r"[A-Za-z_][A-Za-z0-9_.]*\s*\(", rhs):
bad.append("%s: {%% set%s%%}" % (p.name, rhs.strip()))
print("\n".join(bad))
PY_SET
)"
if [ -n "$set_call" ]; then
fail "a {% set %} tag assigns a function call; utpl rejects it, and the parse error"
fail "zeroes the whole render instead of failing visibly:"
printf ' %s\n' "$set_call"
else
pass "no {% set %} tag calls a function (utpl would reject it and the whole render"
pass " would silently collapse to an empty ruleset)"
fi
echo
printf '%s checks, %s failures\n' "$checks" "$([ "$FAILED" = 0 ] && echo 0 || echo 'nonzero')"
if [ "$FAILED" != 0 ]; then
echo "ARCHITECTURE GUARD FAILED"
exit 1
fi
echo "ARCHITECTURE GUARD PASSED"
exit 0