mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-06 03:14:12 +08:00
🎉 Sync 2026-09-27 21:03:26
This commit is contained in:
1 parent
f4559bbed1
commit
065763f7fd
673 files changed
+261285
-34160
No files matched your search
@@ -0,0 +1,292 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Copyright (C) 2025 ImmortalWrt.org
|
||||
*
|
||||
* Test double for root/etc/homeproxy/scripts/homeproxy.uc.
|
||||
*
|
||||
* Only `validation()` is stubbed: the real one shells out to
|
||||
* /sbin/validate_data, which does not exist outside OpenWrt. That stand-in
|
||||
* reproduces the `libvalidate` decisions for the five datatypes the callers
|
||||
* actually use - see the comment on validation() below for the contract and
|
||||
* the known differences. Everything else (isEmpty, decodeBase64Str, parseURL,
|
||||
* shellQuote, redactUrl) is a verbatim copy so the parser tests exercise the
|
||||
* same string handling as production. Keep these in sync when homeproxy.uc
|
||||
* changes; tests/ucode/test_mock_sync.sh enforces that for everything except
|
||||
* validation().
|
||||
*/
|
||||
|
||||
import { urldecode_params } from 'luci.http';
|
||||
|
||||
/* Verbatim copy of homeproxy.uc:shellQuote - migrate_config.uc imports it to
|
||||
* build the crontab rewrite, so it has to exist here too. Compared against
|
||||
* production by tests/ucode/test_mock_sync.sh. */
|
||||
export function shellQuote(s) {
|
||||
return `'${replace(s, "'", "'\\''")}'`;
|
||||
};
|
||||
|
||||
export function isEmpty(res) {
|
||||
return !res || res === 'nil' || (type(res) in ['array', 'object'] && length(res) === 0);
|
||||
};
|
||||
|
||||
export function decodeBase64Str(str) {
|
||||
if (isEmpty(str))
|
||||
return null;
|
||||
|
||||
str = trim(str);
|
||||
str = replace(str, /_/g, '/');
|
||||
str = replace(str, /-/g, '+');
|
||||
|
||||
const padding = length(str) % 4;
|
||||
if (padding)
|
||||
str = str + substr('====', padding);
|
||||
|
||||
return b64dec(str);
|
||||
};
|
||||
|
||||
/* Verbatim copy of homeproxy.uc:redactUrl - decoder.uc imports it so the
|
||||
* subscription token stays out of the parse-failed log line. */
|
||||
export function redactUrl(url) {
|
||||
if (!url || type(url) !== 'string')
|
||||
return '';
|
||||
|
||||
let u = url;
|
||||
|
||||
/* userinfo: scheme://user:pass@host -> scheme://***@host */
|
||||
const at = index(u, '@');
|
||||
const scheme = match(u, /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//);
|
||||
if (scheme && at !== -1 && at > length(scheme[0]))
|
||||
u = substr(u, 0, length(scheme[0])) + '***' + substr(u, at);
|
||||
|
||||
/* path and query: keep the authority, mark the rest. Splitting them
|
||||
* rather than masking from the first '/' keeps the structural markers
|
||||
* (`/***?***`) so a reader can still tell a path from a query, and it is
|
||||
* why the authority end is the *earlier* of the two separators. */
|
||||
const scheme_end = scheme ? length(scheme[0]) : 0;
|
||||
const rest = substr(u, scheme_end);
|
||||
const path_at = index(rest, '/');
|
||||
const query_at = index(rest, '?');
|
||||
const has_path = path_at !== -1 && (query_at === -1 || path_at < query_at);
|
||||
|
||||
let authority_end = length(rest);
|
||||
if (has_path)
|
||||
authority_end = path_at;
|
||||
else if (query_at !== -1)
|
||||
authority_end = query_at;
|
||||
|
||||
let tail = '';
|
||||
if (has_path)
|
||||
tail += '/***';
|
||||
if (query_at !== -1)
|
||||
tail += '?***';
|
||||
|
||||
u = substr(u, 0, scheme_end + authority_end) + tail;
|
||||
|
||||
return u;
|
||||
};
|
||||
|
||||
/* inet_pton(AF_INET, ...): four decimal octets, 0-255, no leading zeros.
|
||||
* A helper rather than a call through validation(), because ucode resolves
|
||||
* module names lexically and validation() is declared below these. */
|
||||
function isValidIp4(data) {
|
||||
if (match(data, /^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$/) === null)
|
||||
return false;
|
||||
for (let octet in split(data, '.'))
|
||||
if (int(octet) > 255 || (length(octet) > 1 && substr(octet, 0, 1) === '0'))
|
||||
return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
/* inet_pton(AF_INET6, ...) for the textual forms a share link can carry:
|
||||
* at most one "::", 1-4 hex digits per group, exactly eight groups unless
|
||||
* "::" fills the gap, and an optional dotted-quad tail (the last 32 bits)
|
||||
* that counts as two groups. A "%zone" suffix, a CIDR prefix and surrounding
|
||||
* whitespace are all rejected, exactly as inet_pton rejects them. */
|
||||
function isValidIp6(data) {
|
||||
if (match(data, /^[0-9A-Fa-f:.]+$/) == null)
|
||||
return false;
|
||||
|
||||
/* A dotted-quad must be the tail; rewrite it as the two hex groups it
|
||||
* stands for, so the rest of the parse only sees "[0-9A-Fa-f:]". */
|
||||
if (index(data, '.') !== -1) {
|
||||
const quad = match(data, /^(.*:)([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})$/);
|
||||
if (quad === null || !isValidIp4(quad[2]))
|
||||
return false;
|
||||
|
||||
const octets = split(quad[2], '.');
|
||||
data = quad[1] + sprintf('%02x%02x:%02x%02x',
|
||||
int(octets[0]), int(octets[1]), int(octets[2]), int(octets[3]));
|
||||
}
|
||||
|
||||
const dbl = index(data, '::');
|
||||
let left = data, right = '';
|
||||
|
||||
if (dbl !== -1) {
|
||||
if (index(substr(data, dbl + 2), '::') !== -1)
|
||||
return false;
|
||||
left = substr(data, 0, dbl);
|
||||
right = substr(data, dbl + 2);
|
||||
}
|
||||
|
||||
let explicit = 0;
|
||||
for (let part in [ left, right ]) {
|
||||
if (part === '')
|
||||
continue;
|
||||
const groups = split(part, ':');
|
||||
for (let group in groups)
|
||||
if (match(group, /^[0-9A-Fa-f]{1,4}$/) === null)
|
||||
return false;
|
||||
explicit += length(groups);
|
||||
}
|
||||
|
||||
/* "::" has to stand for at least one group, so with it at most seven
|
||||
* groups may be written out; without it there must be exactly eight. */
|
||||
return dbl === -1 ? explicit === 8 : explicit < 8;
|
||||
};
|
||||
|
||||
/* Pure-ucode stand-in for the datatypes homeproxy.uc asks /sbin/validate_data
|
||||
* to check. Production shells out to OpenWrt's `libvalidate` (ubox
|
||||
* src/validate.c); off-target this function is what the parser unit tests and
|
||||
* tests/toolchain/validate-data.sh exercise, so it answers the same way the
|
||||
* real binary does for those five types:
|
||||
*
|
||||
* ip4addr inet_pton(AF_INET) - four decimal octets, 0-255, no leading
|
||||
* zeros ("01.2.3.4" and "1.2.3.04" are invalid)
|
||||
* ip6addr inet_pton(AF_INET6) - see isValidIp6 above; "2001:db8::1",
|
||||
* "::ffff:1.2.3.4" and "1:2:3:4:5:6:7:8" are
|
||||
* valid, "12345::1" and "fe80::1%eth0" are not
|
||||
* hostname dt_type_hostname - dot-separated labels of [A-Za-z0-9_-], each
|
||||
* 1..63 chars, the final label 1..255 chars;
|
||||
* "-a", "a-" and "a_b" are valid, "a..b",
|
||||
* "a%b", "foo." and ".foo" are not
|
||||
* host hostname || ip4addr || ip6addr
|
||||
* port dt_type_port - strtoul(v, &e, 10) with the whole string consumed
|
||||
* and the value compared as a signed 32-bit
|
||||
* int, so "-1" and " 80" are accepted and
|
||||
* "65536"/"80 " are not
|
||||
*
|
||||
* Known differences, none reachable from the callers:
|
||||
* - only those five types are modelled. The real binary also validates
|
||||
* macaddr, cidr, netmask, file/directory, uci references, ...; an unknown
|
||||
* type exits non-zero there (falsy) and returns false here.
|
||||
* - strtoul()'s saturation above 2^64 is not reproduced: a digit string
|
||||
* that long would wrap instead of clamping, so a port > 2^64 might
|
||||
* disagree. No share link carries one.
|
||||
*
|
||||
* tests/ucode/test_mock_sync.sh deliberately excludes this function from its
|
||||
* production-vs-mock corpus (it is a stand-in, not a copy); it is pinned by
|
||||
* the parser tests and by tests/toolchain/validate-data.sh. */
|
||||
export function validation(datatype, data) {
|
||||
if (!datatype || !data)
|
||||
return null;
|
||||
|
||||
switch (datatype) {
|
||||
case 'port': {
|
||||
const parts = match(data, /^[ \t\n\v\f\r]*([+-]?)([0-9]+)$/);
|
||||
if (parts === null)
|
||||
return false;
|
||||
|
||||
let n = 0;
|
||||
for (let digit in split(parts[2], ''))
|
||||
n = (n * 10 + int(digit)) % 4294967296;
|
||||
|
||||
if (parts[1] === '-' && n !== 0)
|
||||
n = 4294967296 - n;
|
||||
if (n > 2147483647)
|
||||
n -= 4294967296;
|
||||
|
||||
return n <= 65535;
|
||||
}
|
||||
case 'ip4addr':
|
||||
return isValidIp4(data);
|
||||
case 'ip6addr':
|
||||
return isValidIp6(data);
|
||||
case 'hostname':
|
||||
/* dt_type_hostname: every dot-terminated label is 1..63 chars, the
|
||||
* last one 1..255, all from [A-Za-z0-9_-]. */
|
||||
return match(data, /^([A-Za-z0-9_-]{1,63}\.)*[A-Za-z0-9_-]{1,255}$/) !== null;
|
||||
case 'host':
|
||||
return validation('ip4addr', data) === true ||
|
||||
validation('ip6addr', data) === true ||
|
||||
validation('hostname', data) === true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export function parseURL(url) {
|
||||
if (type(url) !== 'string')
|
||||
return null;
|
||||
|
||||
const services = {
|
||||
http: '80',
|
||||
https: '443'
|
||||
};
|
||||
|
||||
const objurl = {};
|
||||
|
||||
objurl.href = url;
|
||||
|
||||
url = replace(url, /#(.+)$/, (_, val) => {
|
||||
objurl.hash = val;
|
||||
return '';
|
||||
});
|
||||
|
||||
url = replace(url, /^(\w[A-Za-z0-9\+\-\.]+):/, (_, val) => {
|
||||
objurl.protocol = val;
|
||||
return '';
|
||||
});
|
||||
|
||||
url = replace(url, /\?(.+)/, (_, val) => {
|
||||
objurl.search = val;
|
||||
objurl.searchParams = urldecode_params(val);
|
||||
return '';
|
||||
});
|
||||
|
||||
url = replace(url, /^\/\/([^\/]+)/, (_, val) => {
|
||||
val = replace(val, /^([^@]+)@/, (_, val) => {
|
||||
objurl.userinfo = val;
|
||||
return '';
|
||||
});
|
||||
|
||||
val = replace(val, /:(\d+)$/, (_, val) => {
|
||||
objurl.port = val;
|
||||
return '';
|
||||
});
|
||||
|
||||
if (validation('ip4addr', val) ||
|
||||
validation('ip6addr', replace(val, /\[|\]/g, '')) ||
|
||||
validation('hostname', val))
|
||||
objurl.hostname = val;
|
||||
|
||||
return '';
|
||||
});
|
||||
|
||||
objurl.pathname = url || '/';
|
||||
|
||||
if (!objurl.protocol || !objurl.hostname)
|
||||
return null;
|
||||
|
||||
if (objurl.userinfo) {
|
||||
objurl.userinfo = replace(objurl.userinfo, /:(.+)$/, (_, val) => {
|
||||
objurl.password = val;
|
||||
return '';
|
||||
});
|
||||
|
||||
if (match(objurl.userinfo, /^[A-Za-z0-9\+\-\_\.]+$/)) {
|
||||
objurl.username = objurl.userinfo;
|
||||
delete objurl.userinfo;
|
||||
} else {
|
||||
delete objurl.userinfo;
|
||||
delete objurl.password;
|
||||
}
|
||||
};
|
||||
|
||||
if (!objurl.port)
|
||||
objurl.port = services[objurl.protocol];
|
||||
|
||||
objurl.host = objurl.hostname + (objurl.port ? `:${objurl.port}` : '');
|
||||
objurl.origin = `${objurl.protocol}://${objurl.host}`;
|
||||
|
||||
return objurl;
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* homeproxy module shim for subscription/fetcher unit tests.
|
||||
*
|
||||
* The real homeproxy.uc wraps wGETVerbose around `executeCommand(
|
||||
* /usr/bin/wget ...)`, which means a unit test that imports it
|
||||
* would shell out to the network. Tests stage this file as
|
||||
* `homeproxy` in their -L directory so the fetcher picks up the
|
||||
* shim's wGETVerbose instead.
|
||||
*
|
||||
* The shim reads HP_TEST_WGET_CONTENT / HP_TEST_WGET_ERROR globals
|
||||
* (set by the test before each fetch call) and returns them. The
|
||||
* two other names the fetcher imports (isEmpty, redactUrl) are
|
||||
* short enough to copy verbatim from homeproxy.uc so the shim is
|
||||
* fully self-contained - the real homeproxy.uc also imports
|
||||
* validate_data / popen / a long list of luci.* modules that the
|
||||
* testbed does not have on PATH, and pulling the full file would
|
||||
* pull those imports along.
|
||||
*
|
||||
* The `global` prefix is the only cross-module shared state ucode
|
||||
* exposes without a dependency-injection hook, and the test is the
|
||||
* only writer.
|
||||
*/
|
||||
|
||||
export function wGETVerbose(url, _ua) {
|
||||
const content = global.HP_TEST_WGET_CONTENT;
|
||||
const error = global.HP_TEST_WGET_ERROR;
|
||||
return { content: content, error: error };
|
||||
};
|
||||
|
||||
export function isEmpty(res) {
|
||||
return !res || res === 'nil' || (type(res) in ['array', 'object'] && length(res) === 0);
|
||||
};
|
||||
|
||||
/* Verbatim copy of homeproxyuc:redactUrl - keeps the security
|
||||
* behaviour covered in test_subscription_fetcher.uc. */
|
||||
export function redactUrl(url) {
|
||||
if (!url || type(url) !== 'string')
|
||||
return '';
|
||||
|
||||
let u = url;
|
||||
|
||||
/* userinfo: scheme://user:pass@host -> scheme://***@host */
|
||||
const at = index(u, '@');
|
||||
const scheme = match(u, /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//);
|
||||
if (scheme && at !== -1 && at > length(scheme[0]))
|
||||
u = substr(u, 0, length(scheme[0])) + '***' + substr(u, at);
|
||||
|
||||
/* path and query: keep the authority, mark the rest. Splitting them
|
||||
* rather than masking from the first '/' keeps the structural markers
|
||||
* (`/***?***`) so a reader can still tell a path from a query, and it is
|
||||
* why the authority end is the *earlier* of the two separators. */
|
||||
const scheme_end = scheme ? length(scheme[0]) : 0;
|
||||
const rest = substr(u, scheme_end);
|
||||
const path_at = index(rest, '/');
|
||||
const query_at = index(rest, '?');
|
||||
const has_path = path_at !== -1 && (query_at === -1 || path_at < query_at);
|
||||
|
||||
let authority_end = length(rest);
|
||||
if (has_path)
|
||||
authority_end = path_at;
|
||||
else if (query_at !== -1)
|
||||
authority_end = query_at;
|
||||
|
||||
let tail = '';
|
||||
if (has_path)
|
||||
tail += '/***';
|
||||
if (query_at !== -1)
|
||||
tail += '?***';
|
||||
|
||||
u = substr(u, 0, scheme_end + authority_end) + tail;
|
||||
|
||||
return u;
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Test double for root/etc/homeproxy/scripts/homeproxy.uc, scoped to
|
||||
* what firewall_pre.uc imports: isEmpty, RUN_DIR, validation.
|
||||
*
|
||||
* RUN_DIR is read from the HP_FW_RUN_DIR global so the test can
|
||||
* point the script at a scratch dir; firewall_pre.uc writes
|
||||
* fw4_forward.nft / fw4_input.nft into RUN_DIR, and the test must
|
||||
* not clobber /var/run/homeproxy on a host that happens to have it.
|
||||
*
|
||||
* validation() is the same pure-ucode stand-in the parser tests use
|
||||
* (the production one shells out to /sbin/validate_data, which does
|
||||
* not exist off-target). Keep it in sync with mocks/homeproxy.uc.
|
||||
*/
|
||||
|
||||
export function isEmpty(res) {
|
||||
return !res || res === 'nil' || (type(res) in ['array', 'object'] && length(res) === 0);
|
||||
};
|
||||
|
||||
export const RUN_DIR = global.HP_FW_RUN_DIR;
|
||||
|
||||
/* Pure-ucode stand-in for /sbin/validate_data (port / ip4 / ip6 / hostname). */
|
||||
export function validation(datatype, data) {
|
||||
if (!datatype || !data)
|
||||
return null;
|
||||
|
||||
switch (datatype) {
|
||||
case 'port':
|
||||
return match(data, /^\d+$/) != null && int(data) >= 0 && int(data) <= 65535;
|
||||
case 'ip4addr':
|
||||
if (match(data, /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/) == null)
|
||||
return false;
|
||||
for (let octet in split(data, '.'))
|
||||
if (int(octet) > 255)
|
||||
return false;
|
||||
return true;
|
||||
case 'ip6addr':
|
||||
return match(data, /^[0-9a-fA-F:]+$/) != null && match(data, /::.*::/) == null;
|
||||
case 'hostname':
|
||||
return match(data, /^[A-Za-z0-9_][A-Za-z0-9_%-.]*[A-Za-z0-9]$/) != null ||
|
||||
match(data, /^[A-Za-z0-9_]$/) != null;
|
||||
case 'host':
|
||||
return validation('ip4addr', data) === true ||
|
||||
validation('ip6addr', data) === true ||
|
||||
validation('hostname', data) === true;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
Executable
+502
@@ -0,0 +1,502 @@
|
||||
#!/bin/sh
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# Run the ucode-level tests: the parse_uri unit tests, the fw4 inventory check
|
||||
# and the generator regression fixtures. Requires ucode; sing-box is needed for
|
||||
# the generator cases (they validate the emitted config with `sing-box check`).
|
||||
#
|
||||
# Usage: sh tests/ucode/run.sh <repo-root> [work-dir]
|
||||
|
||||
ROOT="${1:-.}"
|
||||
WORK="${2:-/tmp/hp-ucode-tests}"
|
||||
|
||||
ROOT="$(cd "$ROOT" && pwd)"
|
||||
FAILED=0
|
||||
|
||||
if ! command -v ucode > "/dev/null" 2>&1; then
|
||||
echo "NOT RUN: ucode is not on PATH."
|
||||
echo " Build the toolchain first (Linux; the target and CI are both"
|
||||
echo " Linux, and sing-box only accepts routing_mark there), or use"
|
||||
echo " tests/run.sh, which copies the checkout to a device when the"
|
||||
echo " host has no ucode:"
|
||||
echo " sh tests/toolchain/build-ucode-linux.sh"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# The target is OpenWrt (Linux), and this layer validates the generated
|
||||
# configuration with sing-box. routing_mark - the SO_MARK that keeps
|
||||
# sing-box's own proxy connection out of the nft redirect chain - is a
|
||||
# Linux-only field: `sing-box check` rejects it everywhere else, so a
|
||||
# non-Linux host cannot tell a correct configuration from one that lost the
|
||||
# field. The suite used to paper over that by rewriting the field to null
|
||||
# before testing, which is exactly how the regression stayed invisible; saying
|
||||
# "cannot run here" is the honest version of the same fact.
|
||||
if [ "$(uname -s)" != "Linux" ] && [ "${HP_ALLOW_NONLINUX_UCODE:-0}" != "1" ]; then
|
||||
echo "NOT RUN: the ucode layer requires Linux (this host is $(uname -s))."
|
||||
echo " Run it in a Linux container or with"
|
||||
echo " HP_TEST_HOST=root@<test-machine>; tests/README.md has both."
|
||||
echo " HP_ALLOW_NONLINUX_UCODE=1 forces the run anyway - expect the"
|
||||
echo " routing_mark assertions to fail, because the local sing-box"
|
||||
echo " rejects that field."
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# homeproxy.uc validates data through /sbin/validate_data, which only exists on
|
||||
# a target. Off-target the generator cases would fail on the very first
|
||||
# hostname check, so point them at the local stand-in (which in turn uses the
|
||||
# same validation code as the parser unit tests). On a target the production
|
||||
# binary is used unchanged.
|
||||
if [ ! -x /sbin/validate_data ] && [ -x "$ROOT/tests/toolchain/validate-data.sh" ]; then
|
||||
HP_VALIDATE_DATA="$ROOT/tests/toolchain/validate-data.sh"
|
||||
export HP_VALIDATE_DATA
|
||||
fi
|
||||
|
||||
# Nothing in this suite is target-only any more. Every source compiles with
|
||||
# the toolchain from tests/toolchain/build-ucode-*.sh (which ships utpl, the
|
||||
# luci.* ucode modules and a matching sing-box), and the files that import
|
||||
# through an absolute /etc/homeproxy/... path are rewritten to the checkout
|
||||
# below. A missing piece of the toolchain now FAILS instead of being skipped:
|
||||
# the old target-only skips are exactly what let a non-compiling
|
||||
# update_subscriptions.uc, and a destructuring statement, reach the device.
|
||||
SCRIPTS_DIR="$ROOT/root/etc/homeproxy/scripts"
|
||||
mkdir -p "$WORK/syntax"
|
||||
|
||||
echo "== ucode grammar canary =="
|
||||
if ! sh "$ROOT/tests/ucode/test_ucode_grammar.sh"; then
|
||||
echo "FAIL: ucode grammar does not match the target dialect"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
echo "== ucode syntax check =="
|
||||
for file in "$SCRIPTS_DIR"/*.uc \
|
||||
"$SCRIPTS_DIR"/subscription/*.uc \
|
||||
"$SCRIPTS_DIR"/config/*.uc \
|
||||
"$SCRIPTS_DIR"/generator/*.uc \
|
||||
"$ROOT"/root/usr/share/rpcd/ucode/*; do
|
||||
[ -f "$file" ] || continue
|
||||
# Modules (with export statements) cannot be compiled as a program; they
|
||||
# are loaded through `import` below instead.
|
||||
case "$file" in
|
||||
*homeproxy.uc|*firewall_utils.uc|*/parser/*.uc|*/subscription/*.uc|*/config/*.uc|*/generator/*.uc) continue ;;
|
||||
esac
|
||||
|
||||
# luci.homeproxy imports homeproxy.uc through an absolute /etc/... path
|
||||
# that does not exist off-target. Compile a rewritten copy instead of
|
||||
# skipping the file.
|
||||
target="$file"
|
||||
case "$file" in
|
||||
*/usr/share/rpcd/ucode/*)
|
||||
target="$WORK/syntax/$(basename "$file")"
|
||||
sed "s#'/etc/homeproxy/scripts/#'$SCRIPTS_DIR/#g" "$file" > "$target" ;;
|
||||
esac
|
||||
|
||||
if ! ucode -L "$SCRIPTS_DIR" -c -o "/dev/null" "$target" 2> "$WORK/syntax.err"; then
|
||||
echo "FAIL: ${file#"$ROOT"/}"
|
||||
head -8 "$WORK/syntax.err"
|
||||
FAILED=1
|
||||
fi
|
||||
done
|
||||
# Modules are syntax-checked by loading them through `import`. The
|
||||
# -e expression is a no-op program; the import itself is what we
|
||||
# want to validate. Only top-level module names are searched in the
|
||||
# -L tree, so anything in a subdirectory is loaded by absolute path
|
||||
# in the second loop below.
|
||||
for module in homeproxy firewall_utils; do
|
||||
if ! ucode -L "$ROOT/root/etc/homeproxy/scripts" -e "import * as m from \"$module\";" 2> "$WORK/syntax.err"; then
|
||||
echo "FAIL: module $module"
|
||||
head -8 "$WORK/syntax.err"
|
||||
FAILED=1
|
||||
fi
|
||||
done
|
||||
# `parser/*.uc` (added in PR-02), `config/*.uc` and `generator/*.uc` are
|
||||
# imported through relative paths, so a syntax error there only surfaces
|
||||
# when a generator or a test runs; import them explicitly too so the
|
||||
# failure names the module. `generator/client.uc` and `generator/server.uc`
|
||||
# are the public entry points and pull the rest of that subtree in
|
||||
# transitively, which is the cheapest way to syntax-check it.
|
||||
for module in parser/uri parser/protocols parser/validator parser/normalize parser/flatten parser/mapping \
|
||||
subscription/filter subscription/decoder subscription/fetcher subscription/repository \
|
||||
config/loader config/model config/adapter \
|
||||
generator/client generator/server; do
|
||||
if ! ucode -L "$SCRIPTS_DIR" -e "import * as m from \"$SCRIPTS_DIR/$module.uc\";" 2> "$WORK/syntax.err"; then
|
||||
echo "FAIL: module $module"
|
||||
head -8 "$WORK/syntax.err"
|
||||
FAILED=1
|
||||
fi
|
||||
done
|
||||
[ "$FAILED" -eq 0 ] && echo "PASS: all ucode sources compile"
|
||||
|
||||
echo "== fw4 chain/set inventory =="
|
||||
sh "$ROOT/tests/ucode/test_fw4_names.sh" "$ROOT" || FAILED=1
|
||||
|
||||
echo "== shell syntax check =="
|
||||
# init.d/homeproxy and the runtime helpers are shell, so no ucode check covers
|
||||
# them. They decide whether a bad configuration can leave the router without
|
||||
# a service, so a syntax error there is as bad as one in the generators.
|
||||
for file in "$ROOT"/root/etc/init.d/* \
|
||||
"$ROOT"/root/etc/homeproxy/scripts/*.sh \
|
||||
"$ROOT"/root/etc/homeproxy/scripts/runtime/*.sh; do
|
||||
[ -f "$file" ] || continue
|
||||
if ! sh -n "$file" 2> "$WORK/syntax.err"; then
|
||||
echo "FAIL: ${file#"$ROOT"/}"
|
||||
head -5 "$WORK/syntax.err"
|
||||
FAILED=1
|
||||
fi
|
||||
done
|
||||
|
||||
echo "== runtime configuration transaction =="
|
||||
sh "$ROOT/tests/runtime/test_config_transaction.sh" "$ROOT" || FAILED=1
|
||||
|
||||
echo "== dnsmasq snippet writer (review L6) =="
|
||||
# Counts dnsmasq restarts through a stub: an unchanged snippet set must not
|
||||
# restart dnsmasq, a changed one must. Pure shell, no ucode needed.
|
||||
sh "$ROOT/tests/runtime/test_dns_snippets.sh" "$ROOT" "$WORK/dns-snippets" || FAILED=1
|
||||
|
||||
echo "== runtime extraction equivalence (PR-05) =="
|
||||
# Drives the init script through a stubbed environment and compares the
|
||||
# resulting command/file trace against the pre-PR-05 trace. It needs no
|
||||
# ucode, but it belongs in this suite so CI runs it on every PR.
|
||||
# Pass this run's work dir: the test defaults to its own mktemp -d, but keeping
|
||||
# it under one root makes a failed run easier to inspect and avoids a second
|
||||
# temporary tree.
|
||||
sh "$ROOT/tests/runtime/test_runtime_extraction.sh" "$ROOT" "$WORK/runtime-extraction" || FAILED=1
|
||||
|
||||
echo "== firewall template rendering =="
|
||||
# utpl ships with ucode (it is a symlink to the same binary), so this check
|
||||
# runs off-target too. A toolchain without it is incomplete, not a reason to
|
||||
# skip the only test that guards the fw4 statement layout.
|
||||
if ! command -v utpl > "/dev/null" 2>&1; then
|
||||
echo "FAIL: utpl is missing; build the toolchain (tests/toolchain/build-ucode-*.sh)"
|
||||
FAILED=1
|
||||
else
|
||||
sh "$ROOT/tests/ucode/test_firewall_template.sh" "$ROOT" || FAILED=1
|
||||
fi
|
||||
|
||||
echo "== firewall_pre generator behaviour =="
|
||||
# Covers the generator that decides *which* nft statements exist, not just
|
||||
# whether the template renders. Its two validation guards are the reason a
|
||||
# malformed server section cannot take the whole fw4 ruleset down with it,
|
||||
# so they get asserted directly rather than only through a golden file.
|
||||
sh "$ROOT/tests/ucode/test_firewall_pre.sh" "$ROOT" "$WORK/firewall_pre" || FAILED=1
|
||||
|
||||
echo "== firewall field validators =="
|
||||
# The pure-function helpers firewall_post.ut routes every UCI field through
|
||||
# (review H1). Pure functions, no fw4 module needed - so they run on a
|
||||
# development host while the renderer above stays NOT RUN, and the renderer
|
||||
# cannot regress silently because the validators are independently pinned
|
||||
# here. Guard 11 in tests/arch-guard.sh covers the template still calls
|
||||
# them.
|
||||
sh "$ROOT/tests/ucode/test_firewall_validators.sh" "$ROOT" "$WORK/firewall_validators" || FAILED=1
|
||||
|
||||
echo "== parse_uri unit tests =="
|
||||
# PR-02 moved the share-link parsers from parse_uri.uc to
|
||||
# scripts/parser/{uri,protocols,validator,normalize,mapping}.uc.
|
||||
# Stage the whole parser/ tree so test_parse_uri.uc can `import` from
|
||||
# it the same way the production orchestrator does.
|
||||
rm -rf "$WORK/parse_uri"
|
||||
mkdir -p "$WORK/parse_uri/parser"
|
||||
cp "$ROOT/root/etc/homeproxy/scripts/parser/"*.uc "$WORK/parse_uri/parser/"
|
||||
cp "$ROOT/tests/ucode/mocks/homeproxy.uc" "$WORK/parse_uri/"
|
||||
cp "$ROOT/tests/ucode/test_parse_uri.uc" "$WORK/parse_uri/"
|
||||
|
||||
if ( cd "$WORK/parse_uri" && ucode -L "$WORK/parse_uri" test_parse_uri.uc ); then
|
||||
echo "PASS: parse_uri unit tests"
|
||||
else
|
||||
echo "FAIL: parse_uri unit tests"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
echo "== parser/normalize unit tests =="
|
||||
# PR-02: parser/normalize.uc turns the parser's flat UCI-key output
|
||||
# into the canonical Node shape the Adapter reads. The staged
|
||||
# $WORK/parse_uri/ already has parser/ and the homeproxy mock, so we
|
||||
# just drop the test next to test_parse_uri.uc.
|
||||
cp "$ROOT/tests/ucode/test_parser_normalize.uc" "$WORK/parse_uri/"
|
||||
if ( cd "$WORK/parse_uri" && ucode -L "$WORK/parse_uri" test_parser_normalize.uc ); then
|
||||
echo "PASS: parser/normalize unit tests"
|
||||
else
|
||||
echo "FAIL: parser/normalize unit tests"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
echo "== parser/flatten round-trip tests =="
|
||||
# PR-03: parser/flatten.uc is the canonical -> flat inverse. The
|
||||
# round-trip invariant is that flatten(normalize(parse_uri(uri)))
|
||||
# produces the same flat UCI dict the parser would have written
|
||||
# directly, so Repository.apply_nodes can write it verbatim and
|
||||
# the Loader's next read yields the same canonical Node.
|
||||
cp "$ROOT/tests/ucode/test_parser_flatten.uc" "$WORK/parse_uri/"
|
||||
if ( cd "$WORK/parse_uri" && ucode -L "$WORK/parse_uri" test_parser_flatten.uc ); then
|
||||
echo "PASS: parser/flatten round-trip tests"
|
||||
else
|
||||
echo "FAIL: parser/flatten round-trip tests"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
echo "== subscription filter unit tests =="
|
||||
# The production modules live at root/etc/homeproxy/scripts/subscription/
|
||||
# *.uc and are imported by update_subscriptions.uc with a relative path.
|
||||
# For the unit tests we want the modules on a flat search path so their
|
||||
# `from 'homeproxy'` import resolves to the mock, so we stage them at
|
||||
# the work dir top level (drop the subscription/ prefix) and let the
|
||||
# test files import them as bare names.
|
||||
rm -rf "$WORK/subscription"
|
||||
# Mirror the production layout: the module under test lives in subscription/ and
|
||||
# reaches homeproxy.uc one level up (a relative import - production cannot rely on
|
||||
# a module search path, see the note above the syntax check). The tests are the
|
||||
# entry scripts and import the module under test by name, so -L points at
|
||||
# subscription/.
|
||||
rm -rf "$WORK/subscription"
|
||||
mkdir -p "$WORK/subscription"
|
||||
cp "$ROOT/tests/ucode/mocks/homeproxy.uc" "$WORK/homeproxy.uc"
|
||||
cp "$ROOT/root/etc/homeproxy/scripts/subscription/filter.uc" "$WORK/subscription/filter.uc"
|
||||
cp "$ROOT/root/etc/homeproxy/scripts/subscription/decoder.uc" "$WORK/subscription/decoder.uc"
|
||||
# The filter test also asserts that applying the policy to the canonical Node
|
||||
# writes the same UCI keys the old flat order did, so it drives the real
|
||||
# normalize()/flatten() pair. Those live in parser/ and import ./mapping.uc
|
||||
# relatively, so all three are staged together next to the test.
|
||||
mkdir -p "$WORK/parser"
|
||||
cp "$ROOT/root/etc/homeproxy/scripts/parser/normalize.uc" \
|
||||
"$ROOT/root/etc/homeproxy/scripts/parser/flatten.uc" \
|
||||
"$ROOT/root/etc/homeproxy/scripts/parser/mapping.uc" "$WORK/parser/"
|
||||
cp "$ROOT/tests/ucode/test_subscription_filter.uc" "$WORK/"
|
||||
cp "$ROOT/tests/ucode/test_subscription_decoder.uc" "$WORK/"
|
||||
|
||||
if ( cd "$WORK" && ucode -L "$WORK/subscription" test_subscription_filter.uc ); then
|
||||
echo "PASS: subscription filter unit tests"
|
||||
else
|
||||
echo "FAIL: subscription filter unit tests"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
if ( cd "$WORK" && ucode -L "$WORK/subscription" test_subscription_decoder.uc ); then
|
||||
echo "PASS: subscription decoder unit tests"
|
||||
else
|
||||
echo "FAIL: subscription decoder unit tests"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
echo "== subscription repository integration test =="
|
||||
# The repository writes to a real UCI cursor, so its testbed needs
|
||||
# the uci + digest shared objects. tests/ucode/run.sh exports
|
||||
# UCODE_MODULES_DIR for that; the test runner script itself stages
|
||||
# a sandboxed config dir with seed sections.
|
||||
sh "$ROOT/tests/ucode/test_subscription_repository.sh" "$ROOT" "$WORK/subscription_repo" || FAILED=1
|
||||
|
||||
echo "== subscription updater actually runs =="
|
||||
# Drives update_subscriptions.uc end to end, which nothing did before: the
|
||||
# script read subscription_urls off the wrong object, so `call(main)` never
|
||||
# ran and both the LuCI button and the cron entry were silent no-ops. The
|
||||
# URL points at a closed port, so main() fails before its reload call.
|
||||
sh "$ROOT/tests/ucode/test_subscription_updater_runs.sh" "$ROOT" "$WORK/updater_runs" || FAILED=1
|
||||
|
||||
echo "== rpcd method behaviour =="
|
||||
# The rpcd module used to be syntax-checked and never executed, which is how
|
||||
# certificate_write('client_ech_conf') stayed broken from the initial commit:
|
||||
# the frontend called it, the ACL granted it, and the backend had no case.
|
||||
sh "$ROOT/tests/ucode/test_rpc_methods.sh" "$ROOT" "$WORK/rpc_methods" || FAILED=1
|
||||
|
||||
echo "== the scripts run the way production runs them =="
|
||||
# init.d/homeproxy calls `ucode -S "$HP_DIR/scripts/generate_client.uc"` and the
|
||||
# cron entry runs update_subscriptions.uc through its shebang - neither passes a
|
||||
# module search path. Everything else in this suite passes -L (the harness has
|
||||
# to, to reach the staged tree), which is exactly why a bare
|
||||
# `import ... from 'homeproxy'` inside a subdirectory compiled here for months
|
||||
# and failed on the first real install: ucode resolves a bare specifier relative
|
||||
# to the IMPORTING module's directory, so config/model.uc looked in config/.
|
||||
#
|
||||
# This runs the real script with no -L at all. Only the runtime paths are
|
||||
# redirected, the same way the generator cases do it.
|
||||
PROD="$WORK/prod-invocation"
|
||||
rm -rf "$PROD"
|
||||
mkdir -p "$PROD/scripts" "$PROD/cfg" "$PROD/run" "$PROD/resources"
|
||||
cp -R "$ROOT/root/etc/homeproxy/scripts/." "$PROD/scripts/"
|
||||
sed -e "s#^export const RUN_DIR = '/var/run/homeproxy';#export const RUN_DIR = '$PROD/run';#" \
|
||||
-e "s#^export const HP_DIR = '/etc/homeproxy';#export const HP_DIR = '$PROD';#" \
|
||||
-e "s#^export const UCICONFIG_DIR = '/etc/config';#export const UCICONFIG_DIR = '$PROD/cfg';#" \
|
||||
"$PROD/scripts/homeproxy.uc" > "$PROD/scripts/homeproxy.uc.new"
|
||||
mv -f "$PROD/scripts/homeproxy.uc.new" "$PROD/scripts/homeproxy.uc"
|
||||
cat > "$PROD/cfg/homeproxy" <<'PRODCFG'
|
||||
config homeproxy 'config'
|
||||
option routing_mode 'bypass_mainland_china'
|
||||
option proxy_mode 'tun'
|
||||
option main_node 'nil'
|
||||
option ipv6_support '0'
|
||||
PRODCFG
|
||||
|
||||
# The assertion is about module resolution specifically: whether the generated
|
||||
# config then satisfies `sing-box check` is what the generator cases cover, and
|
||||
# this fixture is deliberately too small for that.
|
||||
( cd "$PROD" && ucode -S scripts/generate_client.uc ) > "$PROD/out.txt" 2>&1
|
||||
if grep -q "Unable to resolve path for module" "$PROD/out.txt"; then
|
||||
echo "FAIL: generate_client.uc cannot resolve its modules without -L:"
|
||||
grep -m2 "Unable to resolve path for module" "$PROD/out.txt"
|
||||
FAILED=1
|
||||
else
|
||||
echo "PASS: generate_client.uc resolves its modules with no search path"
|
||||
fi
|
||||
|
||||
# The syntax check above imports every module with -L, so it cannot see this;
|
||||
# and neither can the updater test, which also passes -L.
|
||||
( cd "$PROD" && ucode -S scripts/update_subscriptions.uc ) > "$PROD/out2.txt" 2>&1
|
||||
if grep -q "Unable to resolve path for module" "$PROD/out2.txt"; then
|
||||
echo "FAIL: update_subscriptions.uc cannot resolve its modules without -L:"
|
||||
grep -m2 "Unable to resolve path for module" "$PROD/out2.txt"
|
||||
FAILED=1
|
||||
else
|
||||
echo "PASS: update_subscriptions.uc resolves its modules with no search path"
|
||||
fi
|
||||
|
||||
echo "== resource blob digest helper (B2) =="
|
||||
# resource_blob_sha.uc is what the resource updater verifies downloads with, so
|
||||
# its digest has to be git's own: sha1("blob <len>\0" + content). The expected
|
||||
# values in the test are `git hash-object` output, and it re-checks the
|
||||
# repository's real resource files against git when the host has it.
|
||||
sh "$ROOT/tests/ucode/test_resource_blob_sha.sh" "$ROOT" "$WORK/resource_blob_sha" || FAILED=1
|
||||
|
||||
echo "== test doubles still match production =="
|
||||
# The mocks copy isEmpty/decodeBase64Str/parseURL/redactUrl from production and
|
||||
# their headers say to keep them in sync; nothing enforced it, and the fetcher's
|
||||
# redaction security assertion is made against the copy. Compared on a shared
|
||||
# corpus, so the mock may be reformatted but not changed in behaviour.
|
||||
sh "$ROOT/tests/ucode/test_mock_sync.sh" "$ROOT" "$WORK/mock_sync" || FAILED=1
|
||||
|
||||
echo "== the factory configuration loads =="
|
||||
# root/etc/config/homeproxy ships to every new install and was never read by
|
||||
# any test; json-assets.js checks its grammar, this drives the real Loader.
|
||||
sh "$ROOT/tests/ucode/test_factory_config.sh" "$ROOT" "$WORK/factory_config" || FAILED=1
|
||||
|
||||
echo "== homeproxy helper tests =="
|
||||
rm -rf "$WORK/homeproxy"
|
||||
mkdir -p "$WORK/homeproxy"
|
||||
cp "$ROOT/root/etc/homeproxy/scripts/homeproxy.uc" "$WORK/homeproxy/"
|
||||
cp "$ROOT/tests/ucode/test_homeproxy_utils.uc" "$WORK/homeproxy/"
|
||||
if ( cd "$WORK/homeproxy" && ucode test_homeproxy_utils.uc ); then
|
||||
echo "PASS: executeCommand() regression tests"
|
||||
else
|
||||
echo "FAIL: executeCommand() regression tests"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
echo "== TLS / transport builder tests =="
|
||||
rm -rf "$WORK/tls_transport"
|
||||
mkdir -p "$WORK/tls_transport"
|
||||
cp "$ROOT/root/etc/homeproxy/scripts/homeproxy.uc" "$WORK/tls_transport/"
|
||||
cp "$ROOT/tests/ucode/test_tls_transport.uc" "$WORK/tls_transport/"
|
||||
if ( cd "$WORK/tls_transport" && ucode test_tls_transport.uc ); then
|
||||
echo "PASS: TLS / transport builder tests"
|
||||
else
|
||||
echo "FAIL: TLS / transport builder tests"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
echo "== subscription fetcher tests =="
|
||||
rm -rf "$WORK/fetcher"
|
||||
mkdir -p "$WORK/fetcher/subscription"
|
||||
cp "$ROOT/tests/ucode/mocks/homeproxy_fetcher.uc" "$WORK/fetcher/homeproxy.uc"
|
||||
cp "$ROOT/root/etc/homeproxy/scripts/subscription/fetcher.uc" "$WORK/fetcher/subscription/fetcher.uc"
|
||||
cp "$ROOT/tests/ucode/test_subscription_fetcher.uc" "$WORK/fetcher/"
|
||||
if ( cd "$WORK/fetcher" && ucode -L "$WORK/fetcher/subscription" test_subscription_fetcher.uc ); then
|
||||
echo "PASS: subscription fetcher tests"
|
||||
else
|
||||
echo "FAIL: subscription fetcher tests"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
echo "== migrate_config regressions =="
|
||||
sh "$ROOT/tests/ucode/test_migrate_config.sh" "$ROOT" "$WORK/migrate" || FAILED=1
|
||||
|
||||
echo "== executeCommand() failure-path test =="
|
||||
rm -rf "$WORK/homeproxy_inject"
|
||||
mkdir -p "$WORK/homeproxy_inject"
|
||||
sed 's|^\t\texitcode = system(.*);|\t\tdie("injected failure");|' \
|
||||
"$ROOT/root/etc/homeproxy/scripts/homeproxy.uc" > "$WORK/homeproxy_inject/homeproxy.uc"
|
||||
|
||||
# Hard guard: if the anchor stops matching, the sed no-ops and the test
|
||||
# passes without ever exercising the exceptional path - the same trap the
|
||||
# other staged rewrites guard against. Refuse to run instead.
|
||||
if ! grep -q 'die("injected failure")' "$WORK/homeproxy_inject/homeproxy.uc"; then
|
||||
echo "FAIL: executeCommand() failure path: could not inject the failure"
|
||||
echo " (the 'exitcode = system(...)' anchor no longer matches)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cp "$ROOT/tests/ucode/test_homeproxy_utils_inject.uc" "$WORK/homeproxy_inject/"
|
||||
if ( cd "$WORK/homeproxy_inject" && ucode test_homeproxy_utils_inject.uc 2>"/dev/null" ); then
|
||||
echo "PASS: executeCommand() failure path"
|
||||
else
|
||||
echo "FAIL: executeCommand() failure path"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
echo "== generator regression tests =="
|
||||
sh "$ROOT/tests/ucode/test_generators.sh" "$ROOT" "$WORK/generators" || FAILED=1
|
||||
|
||||
echo "== golden protocol snapshot =="
|
||||
sh "$ROOT/tests/ucode/test_golden_outbounds.sh" "$ROOT" "$WORK/golden" || FAILED=1
|
||||
|
||||
echo "== inbound adapter unit tests =="
|
||||
# PR-04: InboundFactory's protocol-shape decisions (snell / shadowsocks get
|
||||
# no users[] block, vless / vmess keep flow / alterId per-user, the snell
|
||||
# listener set, the server-only TLS tail, hysteria v1 vs v2 obfs, and the
|
||||
# per-protocol credential requirements). The golden snapshot pins the
|
||||
# emitted JSON for the fixture; this pins the decisions behind it.
|
||||
rm -rf "$WORK/inbound_adapter"
|
||||
mkdir -p "$WORK/inbound_adapter/config" "$WORK/inbound_adapter/parser"
|
||||
# The adapter needs the real homeproxy.uc (buildTLSObject /
|
||||
# buildTransportObject / strTo* / parse_port), not the test double; the
|
||||
# golden tests stage it the same way. HP_VALIDATE_DATA lets a development
|
||||
# host replace /sbin/validate_data, which homeproxy.uc calls.
|
||||
INBOUND_VALIDATE_DATA="${HP_VALIDATE_DATA:-/sbin/validate_data}"
|
||||
sed -e "s#/sbin/validate_data#${INBOUND_VALIDATE_DATA}#" \
|
||||
"$ROOT/root/etc/homeproxy/scripts/homeproxy.uc" > "$WORK/inbound_adapter/homeproxy.uc"
|
||||
cp "$ROOT/root/etc/homeproxy/scripts/config/loader.uc" "$WORK/inbound_adapter/config/"
|
||||
cp "$ROOT/root/etc/homeproxy/scripts/config/model.uc" "$WORK/inbound_adapter/config/"
|
||||
cp "$ROOT/root/etc/homeproxy/scripts/config/adapter.uc" "$WORK/inbound_adapter/config/"
|
||||
cp "$ROOT/root/etc/homeproxy/scripts/parser/"*.uc "$WORK/inbound_adapter/parser/"
|
||||
cp "$ROOT/tests/ucode/test_inbound_adapter.uc" "$WORK/inbound_adapter/"
|
||||
|
||||
if ( cd "$WORK/inbound_adapter" && ucode -L "$WORK/inbound_adapter" test_inbound_adapter.uc ); then
|
||||
echo "PASS: inbound adapter unit tests"
|
||||
else
|
||||
echo "FAIL: inbound adapter unit tests"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
echo "== generator tag helpers =="
|
||||
# get_outbound() emits `cfg-<node>-out`; isDirectOutboundTag() is asked about
|
||||
# that emitted tag, so it has to undo the wrapper before looking the node up.
|
||||
# It did not, and every routing_node answered "not direct".
|
||||
rm -rf "$WORK/gen_common"
|
||||
mkdir -p "$WORK/gen_common/config" "$WORK/gen_common/parser" "$WORK/gen_common/generator"
|
||||
GEN_VALIDATE_DATA="${HP_VALIDATE_DATA:-/sbin/validate_data}"
|
||||
sed -e "s#/sbin/validate_data#${GEN_VALIDATE_DATA}#" \
|
||||
"$ROOT/root/etc/homeproxy/scripts/homeproxy.uc" > "$WORK/gen_common/homeproxy.uc"
|
||||
cp "$ROOT/root/etc/homeproxy/scripts/config/loader.uc" "$WORK/gen_common/config/"
|
||||
cp "$ROOT/root/etc/homeproxy/scripts/config/model.uc" "$WORK/gen_common/config/"
|
||||
cp "$ROOT/root/etc/homeproxy/scripts/config/adapter.uc" "$WORK/gen_common/config/"
|
||||
cp "$ROOT/root/etc/homeproxy/scripts/parser/"*.uc "$WORK/gen_common/parser/"
|
||||
cp "$ROOT/root/etc/homeproxy/scripts/generator/"*.uc "$WORK/gen_common/generator/"
|
||||
cp "$ROOT/tests/ucode/test_generator_common.uc" "$WORK/gen_common/"
|
||||
|
||||
if ( cd "$WORK/gen_common" && ucode -L "$WORK/gen_common" test_generator_common.uc ); then
|
||||
echo "PASS: generator tag helpers"
|
||||
else
|
||||
echo "FAIL: generator tag helpers"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
echo "== golden inbound snapshot =="
|
||||
# PR-04: the server-side counterpart. Before this the server path had no
|
||||
# snapshot, which is how the fixture's unused `listen_port` option survived
|
||||
# (the generated config carried no listen_port at all and sing-box accepted
|
||||
# it, so nothing ever noticed).
|
||||
sh "$ROOT/tests/ucode/test_golden_inbounds.sh" "$ROOT" "$WORK/golden-inbounds" || FAILED=1
|
||||
|
||||
echo "== protocol inventory =="
|
||||
sh "$ROOT/tests/ucode/test_protocol_inventory.sh" "$ROOT" "$WORK/inventory" || FAILED=1
|
||||
|
||||
echo "== domain model skeleton =="
|
||||
sh "$ROOT/tests/ucode/test_domain_model_skeleton.sh" "$ROOT" "$WORK/domain_model" || FAILED=1
|
||||
|
||||
exit $FAILED
|
||||
@@ -0,0 +1,61 @@
|
||||
#!/bin/sh
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# Stage A1.1 skeleton test: the production Config Loader (root/etc/homeproxy/
|
||||
# scripts/config/loader.uc) must read the UCI fixture and produce a
|
||||
# HomeProxyConfig whose general / infra / nodes sub-objects match the
|
||||
# expected values. The other five sub-objects (dns, routing, endpoints,
|
||||
# access_control, server) are intentionally empty `{}` until A1.2 lands.
|
||||
#
|
||||
# Staging layout:
|
||||
# $WORK/scripts/ - test_domain_model_skeleton.uc + homeproxy.uc
|
||||
# $WORK/scripts/config/ - loader.uc + model.uc + adapter.uc
|
||||
# $WORK/scripts/config/homeproxy - UCI file (from fixture)
|
||||
#
|
||||
# The test runs `cd $WORK/scripts && ucode test_domain_model_skeleton.uc`,
|
||||
# so Loader.load('./config') finds both the config/loader.uc module
|
||||
# (relative-imported as ./config/loader.uc) and the homeproxy UCI file
|
||||
# in the same directory.
|
||||
#
|
||||
# Usage: sh tests/ucode/test_domain_model_skeleton.sh <repo-root> [work-dir]
|
||||
|
||||
ROOT="${1:-.}"
|
||||
WORK="${2:-/tmp/hp-domain-model-test}"
|
||||
|
||||
ROOT="$(cd "$ROOT" && pwd)"
|
||||
FAILED=0
|
||||
|
||||
rm -rf "$WORK"
|
||||
mkdir -p "$WORK/scripts/config"
|
||||
|
||||
# HP_VALIDATE_DATA lets a development host replace /sbin/validate_data (see
|
||||
# tests/README.md); on a target the production path is kept.
|
||||
VALIDATE_DATA="${HP_VALIDATE_DATA:-/sbin/validate_data}"
|
||||
sed -e "s#/sbin/validate_data#${VALIDATE_DATA}#" \
|
||||
"$ROOT/root/etc/homeproxy/scripts/homeproxy.uc" > "$WORK/scripts/homeproxy.uc"
|
||||
|
||||
cp "$ROOT/root/etc/homeproxy/scripts/config/loader.uc" "$WORK/scripts/config/"
|
||||
cp "$ROOT/root/etc/homeproxy/scripts/config/model.uc" "$WORK/scripts/config/"
|
||||
cp "$ROOT/root/etc/homeproxy/scripts/config/adapter.uc" "$WORK/scripts/config/"
|
||||
# PR-02: parse_uri.uc moved into scripts/parser/. Stage the whole
|
||||
# tree so the Loader's '../parser/mapping.uc' import resolves inside
|
||||
# the sandbox.
|
||||
mkdir -p "$WORK/scripts/parser"
|
||||
cp "$ROOT/root/etc/homeproxy/scripts/parser/"*.uc "$WORK/scripts/parser/"
|
||||
|
||||
cp "$ROOT/tests/ucode/test_domain_model_skeleton.uc" "$WORK/scripts/"
|
||||
|
||||
# Use the dedicated domain-model fixture (tests/fixtures/generators/domain.uci),
|
||||
# which carries every UCI section the Loader is supposed to read. The
|
||||
# generator regression suite uses client.uci and custom.uci; this fixture
|
||||
# is only consumed by the domain-model test.
|
||||
cp "$ROOT/tests/fixtures/generators/domain.uci" "$WORK/scripts/config/homeproxy"
|
||||
|
||||
if ( cd "$WORK/scripts" && ucode -L "$WORK/scripts" test_domain_model_skeleton.uc ); then
|
||||
echo "PASS: domain model skeleton"
|
||||
else
|
||||
echo "FAIL: domain model skeleton"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
exit $FAILED
|
||||
@@ -0,0 +1,263 @@
|
||||
#!/usr/bin/ucode
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Stage A1.2 domain-model test: assert that Loader.load(fixture) populates
|
||||
* every HomeProxyConfig sub-object from the corresponding UCI section(s).
|
||||
*
|
||||
* general <- config (single)
|
||||
* infra <- infra (single)
|
||||
* nodes <- node (list)
|
||||
* dns <- dns (single) + dns_server (list) + dns_rule (list)
|
||||
* routing <- routing (single) + routing_node / routing_rule /
|
||||
* ruleset (lists)
|
||||
* access_control <- control (single) + subscription (single)
|
||||
* server <- server (single) + server (list)
|
||||
* endpoints <- DERIVED (stays {}; A3 / A4 fill it)
|
||||
*
|
||||
* The fixture (tests/fixtures/generators/domain.uci) carries at least one
|
||||
* row per section so the assertions exercise both single-section reads
|
||||
* and list reads, plus list-valued options (`wan_proxy_ipv4_ips`,
|
||||
* `filter_keywords`, `subscription_url`).
|
||||
*
|
||||
* Run from tests/ucode/run.sh, which stages this file next to a scratch
|
||||
* homeproxy.uc and config/loader.uc + config/model.uc.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { Loader } from './config/loader.uc';
|
||||
import { Config, Node, ConfigQuery } from './config/model.uc';
|
||||
|
||||
let failures = 0;
|
||||
let checks = 0;
|
||||
|
||||
function expect(name, actual, expected) {
|
||||
checks++;
|
||||
if (sprintf('%J', actual) !== sprintf('%J', expected)) {
|
||||
printf('FAIL %s: expected %J, got %J\n', name, expected, actual);
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
|
||||
function expect_null(name, value) {
|
||||
checks++;
|
||||
if (value !== null && value !== undefined) {
|
||||
printf('FAIL %s: expected null, got %J\n', name, value);
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
|
||||
function by_id(items, id) {
|
||||
for (let it in items)
|
||||
if (it.id === id)
|
||||
return it;
|
||||
return null;
|
||||
}
|
||||
|
||||
const config = Loader.load('./config');
|
||||
|
||||
/* --- general --------------------------------------------------------- */
|
||||
expect('general.routing_mode', config.general.routing_mode, 'bypass_mainland_china');
|
||||
expect('general.proxy_mode', config.general.proxy_mode, 'redirect_tproxy');
|
||||
expect('general.main_node', config.general.main_node, 'urltest');
|
||||
expect('general.main_udp_node', config.general.main_udp_node, 'nil');
|
||||
expect('general.ipv6_support', config.general.ipv6_support, '0');
|
||||
|
||||
/* --- infra ----------------------------------------------------------- */
|
||||
expect('infra.dns_port', config.infra.dns_port, '5333');
|
||||
expect('infra.mixed_port', config.infra.mixed_port, '5330');
|
||||
expect('infra.self_mark', config.infra.self_mark, '100');
|
||||
expect('infra.tun_name', config.infra.tun_name, 'singtun0');
|
||||
expect('infra.tproxy_mark', config.infra.tproxy_mark, '101');
|
||||
expect('infra.tun_addr4', config.infra.tun_addr4, '172.19.0.1/30');
|
||||
expect('infra.ntp_server', config.infra.ntp_server, 'time.apple.com');
|
||||
expect('infra.common_port', config.infra.common_port, '22,53,80,443');
|
||||
|
||||
/* --- nodes ----------------------------------------------------------- */
|
||||
expect('nodes.length', length(config.nodes), 2);
|
||||
|
||||
const n_anytls = by_id(config.nodes, 'n_anytls');
|
||||
const n_snell = by_id(config.nodes, 'n_snell');
|
||||
|
||||
expect('n_anytls.type', n_anytls?.type, 'anytls');
|
||||
expect('n_anytls.credentials.password', n_anytls?.credentials?.password, 'secret');
|
||||
expect('n_snell.credentials.psk', n_snell?.credentials?.psk, 'psk123456789012');
|
||||
expect('n_snell.credentials.userkey', n_snell?.credentials?.userkey, 'ukey');
|
||||
|
||||
/* --- dns ------------------------------------------------------------- */
|
||||
expect('dns.settings.default_strategy', config.dns?.settings?.default_strategy, 'prefer_ipv4');
|
||||
expect('dns.settings.default_server', config.dns?.settings?.default_server, 'default-dns');
|
||||
expect('dns.settings.optimistic_cache', config.dns?.settings?.optimistic_cache, '0');
|
||||
expect('dns.settings.dns_timeout', config.dns?.settings?.dns_timeout, '5s');
|
||||
expect('dns.servers.length', length(config.dns?.servers), 1);
|
||||
expect('dns.servers[0].type', config.dns?.servers?.[0]?.type, 'udp');
|
||||
expect('dns.servers[0].server', config.dns?.servers?.[0]?.server, '8.8.8.8');
|
||||
expect('dns.servers[0].name', config.dns?.servers?.[0]?.name, 'ds_main');
|
||||
expect('dns.rules.length', length(config.dns?.rules), 1);
|
||||
expect('dns.rules[0].action', config.dns?.rules?.[0]?.action, 'hijack-dns');
|
||||
expect('dns.rules[0].name', config.dns?.rules?.[0]?.name, 'dr_hijack');
|
||||
/* PR-01: dns_rule has `enabled '1'` in the fixture, the loader must
|
||||
* surface it as a real boolean. */
|
||||
expect('dns.rules[0].enabled', config.dns?.rules?.[0]?.enabled, true);
|
||||
|
||||
/* PR-01: the UCI pseudo-fields (.name / .index / .type) must not
|
||||
* leak into the loaded shape - the loader drops them via
|
||||
* normalize_section(). */
|
||||
for (let s in (config.dns?.servers || [])) {
|
||||
if ('.name' in s || '.index' in s || '.type' in s) {
|
||||
printf('FAIL dns.servers normalisation: pseudo-field leaked %J\n', s);
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
checks++;
|
||||
for (let r in (config.dns?.rules || [])) {
|
||||
if ('.name' in r || '.index' in r || '.type' in r) {
|
||||
printf('FAIL dns.rules normalisation: pseudo-field leaked %J\n', r);
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
checks++;
|
||||
/* PR-01: sections with `enabled` must have it coerced to boolean.
|
||||
* The fixture carries no `enabled` option on dns_server/dns_rule, so
|
||||
* `enabled` is absent rather than coerced - the check is "no
|
||||
* string-shaped `enabled` value sneaked through". */
|
||||
for (let s in (config.dns?.servers || [])) {
|
||||
if ('enabled' in s && type(s.enabled) !== 'bool') {
|
||||
printf('FAIL dns.servers.enabled: not coerced to boolean, got %J\n', s.enabled);
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
checks++;
|
||||
for (let r in (config.dns?.rules || [])) {
|
||||
if ('enabled' in r && type(r.enabled) !== 'bool') {
|
||||
printf('FAIL dns.rules.enabled: not coerced to boolean, got %J\n', r.enabled);
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
checks++;
|
||||
|
||||
/* absent options must NOT appear in settings */
|
||||
if ('client_subnet' in (config.dns?.settings || {})) {
|
||||
printf('FAIL dns.settings.client_subnet: should be omitted, got %J\n', config.dns.settings.client_subnet);
|
||||
failures++;
|
||||
}
|
||||
checks++;
|
||||
|
||||
/* --- routing --------------------------------------------------------- */
|
||||
expect('routing.settings.default_outbound', config.routing?.settings?.default_outbound, 'direct-out');
|
||||
expect('routing.settings.default_outbound_dns', config.routing?.settings?.default_outbound_dns, 'default-dns');
|
||||
expect('routing.settings.find_neighbor', config.routing?.settings?.find_neighbor, '0');
|
||||
expect('routing.settings.udp_timeout', config.routing?.settings?.udp_timeout, '5m');
|
||||
expect('routing.settings.tcpip_stack', config.routing?.settings?.tcpip_stack, 'system');
|
||||
|
||||
expect('routing.nodes.length', length(config.routing?.nodes), 1);
|
||||
expect('routing.nodes[0].label', config.routing?.nodes?.[0]?.label, 'main');
|
||||
expect('routing.nodes[0].node', config.routing?.nodes?.[0]?.node, 'urltest');
|
||||
|
||||
expect('routing.rules.length', length(config.routing?.rules), 1);
|
||||
expect('routing.rules[0].action', config.routing?.rules?.[0]?.action, 'route');
|
||||
expect('routing.rules[0].name', config.routing?.rules?.[0]?.name, 'rr_sniff');
|
||||
|
||||
expect('routing.rulesets.length', length(config.routing?.rulesets), 1);
|
||||
expect('routing.rulesets[0].type', config.routing?.rulesets?.[0]?.type, 'remote');
|
||||
expect('routing.rulesets[0].url', config.routing?.rulesets?.[0]?.url, 'https://example.com/cn.list');
|
||||
expect('routing.rulesets[0].download_detour', config.routing?.rulesets?.[0]?.download_detour, 'main-out');
|
||||
expect('routing.rulesets[0].name', config.routing?.rulesets?.[0]?.name, 'rs_cn');
|
||||
|
||||
/* PR-01: same normalisation guarantees for the routing lists. */
|
||||
for (let n in (config.routing?.nodes || [])) {
|
||||
if ('.name' in n || '.index' in n || '.type' in n) {
|
||||
printf('FAIL routing.nodes normalisation: pseudo-field leaked %J\n', n);
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
checks++;
|
||||
for (let r in (config.routing?.rules || [])) {
|
||||
if ('.name' in r || '.index' in r || '.type' in r) {
|
||||
printf('FAIL routing.rules normalisation: pseudo-field leaked %J\n', r);
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
checks++;
|
||||
for (let rs in (config.routing?.rulesets || [])) {
|
||||
if ('.name' in rs || '.index' in rs || '.type' in rs) {
|
||||
printf('FAIL routing.rulesets normalisation: pseudo-field leaked %J\n', rs);
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
checks++;
|
||||
expect('routing.nodes[0].name', config.routing?.nodes?.[0]?.name, 'rn_main');
|
||||
|
||||
/* --- access_control -------------------------------------------------- */
|
||||
expect('access_control.control.lan_proxy_mode', config.access_control?.control?.lan_proxy_mode, 'disabled');
|
||||
expect_null('access_control.control.bind_interface', config.access_control?.control?.bind_interface);
|
||||
|
||||
expect('access_control.wan_proxy_ipv4_ips', config.access_control?.wan_proxy_ipv4_ips, ['91.108.4.0/22']);
|
||||
expect('access_control.wan_proxy_ipv6_ips', config.access_control?.wan_proxy_ipv6_ips, ['2001:67c:4e8::/48']);
|
||||
|
||||
expect('access_control.subscription.auto_update', config.access_control?.subscription?.auto_update, '0');
|
||||
expect('access_control.subscription.packet_encoding', config.access_control?.subscription?.packet_encoding, 'xudp');
|
||||
expect('access_control.subscription.filter_nodes', config.access_control?.subscription?.filter_nodes, 'blacklist');
|
||||
expect('access_control.subscription_urls', config.access_control?.subscription_urls, ['https://example.com/sub']);
|
||||
expect('access_control.filter_keywords', config.access_control?.filter_keywords,
|
||||
['重置|到期', 'Expiration']);
|
||||
|
||||
/* --- server ---------------------------------------------------------- */
|
||||
expect('server.settings.enabled', config.server?.settings?.enabled, '1');
|
||||
expect('server.settings.log_level', config.server?.settings?.log_level, 'warn');
|
||||
expect('server.inbounds.length', length(config.server?.inbounds), 1);
|
||||
expect('server.inbounds[0].type', config.server?.inbounds?.[0]?.type, 'vless');
|
||||
expect('server.inbounds[0].port', config.server?.inbounds?.[0]?.port, '443');
|
||||
/* PR-04: server inbounds are Inbound domain objects now, so `id` is the
|
||||
* UCI section name and `name` is the user-facing label (falling back to
|
||||
* the section name). Before PR-04 they were bare normalised sections and
|
||||
* `name` held the section name. */
|
||||
expect('server.inbounds[0].id', config.server?.inbounds?.[0]?.id, 's_vless');
|
||||
expect('server.inbounds[0].name', config.server?.inbounds?.[0]?.name, 'server-vless');
|
||||
expect('server.inbounds[0].enabled', config.server?.inbounds?.[0]?.enabled, true);
|
||||
|
||||
/* PR-01: server inbounds must be normalised too - no UCI pseudo-field
|
||||
* may leak into the Inbound shape. */
|
||||
for (let ib in (config.server?.inbounds || [])) {
|
||||
if ('.name' in ib || '.index' in ib || '.type' in ib) {
|
||||
printf('FAIL server.inbounds normalisation: pseudo-field leaked %J\n', ib);
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
checks++;
|
||||
|
||||
/* PR-04: the Inbound sub-objects must be present so the Adapter can
|
||||
* shape sing-box JSON without falling back to flat UCI reads. */
|
||||
expect('server.inbounds[0].credentials.uuid', config.server?.inbounds?.[0]?.credentials?.uuid,
|
||||
'3af88561-9c69-4b19-8f7e-f08d580bc339');
|
||||
expect('server.inbounds[0].tls.enabled', config.server?.inbounds?.[0]?.tls?.enabled, '1');
|
||||
expect('server.inbounds[0].tls.server_name', config.server?.inbounds?.[0]?.tls?.server_name,
|
||||
'server.example.com');
|
||||
expect('server.inbounds[0].protocol_options.flow', config.server?.inbounds?.[0]?.protocol_options?.flow,
|
||||
null);
|
||||
expect('server.inbounds[0].common keys are the listener set',
|
||||
sort(keys(config.server?.inbounds?.[0]?.common || {})),
|
||||
sort(['bind_interface', 'reuse_addr', 'tcp_fast_open', 'tcp_multi_path',
|
||||
'udp_fragment', 'udp_timeout', 'network']));
|
||||
|
||||
/* --- PR-01 removed the Config.endpoints placeholder and the dead
|
||||
* ConfigQuery.endpoints / node_ids / main_node_id helpers; the
|
||||
* remaining helpers (node_by_id, find_by_name, main_udp_node_id)
|
||||
* are still wired up. */
|
||||
expect('main_udp_node_id', ConfigQuery.main_udp_node_id(config), 'nil');
|
||||
expect('node_by_id(n_anytls).type', ConfigQuery.node_by_id(config, 'n_anytls')?.type, 'anytls');
|
||||
expect('node_by_id(missing)', ConfigQuery.node_by_id(config, 'missing'), null);
|
||||
|
||||
/* find_by_name is exercised against the normalised routing_node list:
|
||||
* `name` is the UCI section name and the lookup must find it. */
|
||||
{
|
||||
let rn = null;
|
||||
for (let n in (config.routing?.nodes || []))
|
||||
if (n.name === 'rn_main') rn = n;
|
||||
expect('find_by_name(routing.nodes, rn_main).node',
|
||||
rn?.node, 'urltest');
|
||||
}
|
||||
|
||||
printf('%d checks, %d failures\n', checks, failures);
|
||||
exit(failures === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,93 @@
|
||||
#!/bin/sh
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# The factory configuration must be readable by the Loader.
|
||||
#
|
||||
# root/etc/config/homeproxy ships as the package conffile and is what every
|
||||
# fresh install starts from, but nothing ever read it: the form tests render
|
||||
# against their own fixtures, the generator tests stage a fixture, and the two
|
||||
# factory-ish UCI files were only ever checked by eye. A typo in a section name
|
||||
# or an option the Loader looks for under a different name would ship to every
|
||||
# new install and only show up as a form full of defaults.
|
||||
#
|
||||
# tests/json-assets.js validates the file's grammar; this drives it through the
|
||||
# real Loader, which is what actually consumes it.
|
||||
#
|
||||
# Usage: sh tests/ucode/test_factory_config.sh <repo-root> [workdir]
|
||||
|
||||
set -u
|
||||
|
||||
ROOT="$(cd "${1:-$(dirname "$0")/../..}" && pwd)"
|
||||
WORK="${2:-/tmp/hp-factory-config}"
|
||||
|
||||
if ! command -v ucode > "/dev/null" 2>&1; then
|
||||
echo "NOT RUN: ucode is not on PATH."
|
||||
exit 2
|
||||
fi
|
||||
|
||||
rm -rf "$WORK"
|
||||
mkdir -p "$WORK/scripts" "$WORK/cfg"
|
||||
|
||||
cp -R "$ROOT/root/etc/homeproxy/scripts/." "$WORK/scripts/"
|
||||
# The factory config is the UCI file itself; the Loader reads <dir>/homeproxy.
|
||||
cp "$ROOT/root/etc/config/homeproxy" "$WORK/cfg/homeproxy"
|
||||
|
||||
FAILED=0
|
||||
if ucode -L "$WORK/scripts" -L "$WORK" -e '
|
||||
import { Loader } from "'"$WORK"'/scripts/config/loader.uc";
|
||||
|
||||
const dm = Loader.load("'"$WORK"'/cfg");
|
||||
const g = dm.general || {};
|
||||
|
||||
let checks = 0, failures = 0;
|
||||
function expect(name, got, want) {
|
||||
checks++;
|
||||
if (sprintf("%J", got) === sprintf("%J", want)) return;
|
||||
failures++;
|
||||
printf("FAIL %s: expected %J, got %J\n", name, want, got);
|
||||
}
|
||||
|
||||
/* Values that are actually written in root/etc/config/homeproxy. If one of
|
||||
* them changes, this test changes with it - the point is that the Loader
|
||||
* reads the shipped file at all, not that these particular defaults are
|
||||
* permanent. */
|
||||
expect("main.routing_mode", g.routing_mode, "bypass_mainland_china");
|
||||
expect("main.proxy_mode", g.proxy_mode, "redirect_tproxy");
|
||||
expect("main.main_node", g.main_node, "nil");
|
||||
expect("main.main_udp_node", g.main_udp_node, "same");
|
||||
expect("main.log_level", g.log_level, "warn");
|
||||
|
||||
/* Values that come from OTHER sections of the same file. Asserting only
|
||||
* that `dm.routing` exists is vacuous - the Loader always builds the
|
||||
* object, from defaults when the section is missing - so a renamed section
|
||||
* slipped straight through the first version of this test. Reading an
|
||||
* actual value through each section is what makes the rename visible. */
|
||||
expect("routing.settings.default_outbound",
|
||||
dm.routing.settings.default_outbound, "direct-out");
|
||||
expect("routing.settings.default_outbound_dns",
|
||||
dm.routing.settings.default_outbound_dns, "default-dns");
|
||||
expect("routing.settings.find_neighbor",
|
||||
dm.routing.settings.find_neighbor, "0");
|
||||
expect("dns.settings.default_strategy",
|
||||
dm.dns.settings.default_strategy, "prefer_ipv4");
|
||||
expect("dns.settings.default_server",
|
||||
dm.dns.settings.default_server, "default-dns");
|
||||
expect("access_control.control.lan_proxy_mode",
|
||||
dm.access_control.control.lan_proxy_mode, "disabled");
|
||||
expect("access_control.wan_proxy_ipv4_ips is non-empty",
|
||||
length(dm.access_control.wan_proxy_ipv4_ips) > 0, true);
|
||||
|
||||
/* The shipped default must not already point at a node. */
|
||||
expect("main.main_node is nil", dm.general.main_node, "nil");
|
||||
|
||||
printf("factory config: %d checks, %d failures\n", checks, failures);
|
||||
exit(failures ? 1 : 0);
|
||||
'; then
|
||||
echo "PASS: the factory configuration loads through the Loader"
|
||||
else
|
||||
echo "FAIL: the factory configuration does not load through the Loader"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
rm -rf "$WORK"
|
||||
exit $FAILED
|
||||
@@ -0,0 +1,313 @@
|
||||
#!/bin/sh
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# Behaviour tests for firewall_pre.uc, the fw4 pre-rules generator.
|
||||
#
|
||||
# firewall_post.ut (the template firewall_pre.uc feeds) already has a
|
||||
# rendering test, but the *generator* that decides which nft
|
||||
# statements exist at all had none. Its two validation guards exist
|
||||
# precisely because a malformed server section would otherwise be
|
||||
# interpolated into a raw `meta l4proto ... th dport ...` statement:
|
||||
#
|
||||
# - an invalid port emits `th dport <garbage>` (nft rejects the
|
||||
# whole ruleset, so the firewall fails to load and the router
|
||||
# loses its ruleset, not just the homeproxy rules)
|
||||
# - an invalid network is interpolated verbatim into the l4proto
|
||||
# position
|
||||
#
|
||||
# Each scenario runs in a fresh process: firewall_pre.uc has no
|
||||
# exports and its body executes at import time, so one process can
|
||||
# only exercise one configuration.
|
||||
#
|
||||
# Cursor + RUN_DIR redirection: firewall_pre.uc reads UCI and writes
|
||||
# its fragments relative to RUN_DIR. The staged *copy* is rewritten
|
||||
# so the cursor takes the sandbox dir and RUN_DIR comes from the
|
||||
# mock. Production source keeps the plain `cursor()` call - the
|
||||
# project removed production test seams with HP_TEST_HOOK and this
|
||||
# keeps it that way.
|
||||
#
|
||||
# Usage: sh tests/ucode/test_firewall_pre.sh <repo-root> [work-dir]
|
||||
|
||||
ROOT="${1:-.}"
|
||||
WORK="${2:-/tmp/hp-firewall-pre-test}"
|
||||
|
||||
ROOT="$(cd "$ROOT" && pwd)"
|
||||
FAILED=0
|
||||
|
||||
STAGE="$WORK/scripts"
|
||||
|
||||
rm -rf "$WORK"
|
||||
mkdir -p "$STAGE"
|
||||
|
||||
cp "$ROOT/tests/ucode/mocks/homeproxy_firewall.uc" "$STAGE/homeproxy.uc"
|
||||
cp "$ROOT/tests/ucode/test_firewall_pre.uc" "$STAGE/"
|
||||
|
||||
# Stage a cursor-redirected copy of firewall_pre.uc, with the shebang
|
||||
# dropped.
|
||||
#
|
||||
# The cursor redirect is the usual no-test-seam-in-production staging: the
|
||||
# test layer rewrites the one line that would otherwise open the live
|
||||
# /etc/config.
|
||||
#
|
||||
# The shebang is dropped because the target ucode rejects a `#!` line when
|
||||
# the file is loaded as a *module* ("Unexpected character"), and this test
|
||||
# imports firewall_pre.uc. Production runs it as a program
|
||||
# (`ucode "$HP_DIR/scripts/firewall_pre.uc"`), where the shebang is both
|
||||
# allowed and meaningful, so this is a staging concern and not something to
|
||||
# change in the source.
|
||||
sed -e '1{/^#!\/usr\/bin\/ucode$/d;}' \
|
||||
-e 's|^const uci = cursor();$|const uci = cursor(ARGV[0]);|' \
|
||||
"$ROOT/root/etc/homeproxy/scripts/firewall_pre.uc" > "$STAGE/firewall_pre.uc"
|
||||
|
||||
# Hard guard: if an anchor stops matching, the sed no-ops. Losing the
|
||||
# cursor redirect means the test would read the *real* /etc/config and write
|
||||
# real nft fragments into /var/run; losing the shebang strip means the
|
||||
# module cannot be imported at all. Refuse to run rather than silently
|
||||
# testing the live configuration.
|
||||
if ! grep -q 'cursor(ARGV\[0\])' "$STAGE/firewall_pre.uc"; then
|
||||
echo "FAIL: firewall_pre: could not redirect the UCI cursor"
|
||||
echo " (the 'const uci = cursor();' anchor no longer matches)"
|
||||
exit 1
|
||||
fi
|
||||
if head -1 "$STAGE/firewall_pre.uc" | grep -q '^#!'; then
|
||||
echo "FAIL: firewall_pre: the shebang was not stripped"
|
||||
echo " (the '#!/usr/bin/ucode' anchor no longer matches)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# seed <scenario> <extra UCI lines...>
|
||||
seed() {
|
||||
scenario="$1"
|
||||
shift
|
||||
mkdir -p "$WORK/$scenario/sandbox" "$WORK/$scenario/run"
|
||||
{
|
||||
printf "config homeproxy 'config'\n"
|
||||
printf "\toption routing_mode '%s'\n" "$ROUTING_MODE"
|
||||
printf "\toption proxy_mode '%s'\n" "$PROXY_MODE"
|
||||
printf "\toption main_node '%s'\n" "$MAIN_NODE"
|
||||
printf "config homeproxy 'infra'\n"
|
||||
printf "\toption tun_name '%s'\n" "${TUN_NAME:-singtun0}"
|
||||
printf "config homeproxy 'server'\n"
|
||||
printf "\toption enabled '%s'\n" "$SERVER_ENABLED"
|
||||
for line in "$@"; do
|
||||
printf '%s\n' "$line"
|
||||
done
|
||||
} > "$WORK/$scenario/sandbox/homeproxy"
|
||||
}
|
||||
|
||||
run_scenario() {
|
||||
scenario="$1"
|
||||
if ( cd "$STAGE" && ucode -L "$STAGE" \
|
||||
test_firewall_pre.uc \
|
||||
"$WORK/$scenario/sandbox" \
|
||||
"$WORK/$scenario/run" \
|
||||
"$scenario" > "$WORK/$scenario/out.txt" 2>&1 ); then
|
||||
cat "$WORK/$scenario/out.txt"
|
||||
else
|
||||
echo "FAIL: firewall_pre scenario '$scenario'"
|
||||
cat "$WORK/$scenario/out.txt"
|
||||
FAILED=1
|
||||
fi
|
||||
}
|
||||
|
||||
# --- scenario: tun mode emits the tun accept pair ---------------------
|
||||
ROUTING_MODE='bypass_mainland_china' PROXY_MODE='tun' MAIN_NODE='urltest' SERVER_ENABLED='0'
|
||||
seed tun
|
||||
run_scenario tun
|
||||
|
||||
# --- scenario: tun mode with no outbound node emits nothing -----------
|
||||
ROUTING_MODE='bypass_mainland_china' PROXY_MODE='tun' MAIN_NODE='nil' SERVER_ENABLED='0'
|
||||
seed no-tun
|
||||
run_scenario no-tun
|
||||
|
||||
# --- scenario: a hostile tun_name is refused, not interpolated --------
|
||||
# It reaches the nft file fw4 loads as root, and the file's own threat model
|
||||
# already validates the server port and network for the same reason.
|
||||
ROUTING_MODE='bypass_mainland_china' PROXY_MODE='tun' MAIN_NODE='urltest' SERVER_ENABLED='0'
|
||||
TUN_NAME='singtun0 } ; chain evil { type filter hook prerouting priority 0; policy accept; }'
|
||||
seed bad-tun
|
||||
run_scenario bad-tun
|
||||
if ! grep -q 'WARN: ignoring invalid tun_name' "$WORK/bad-tun/out.txt"; then
|
||||
echo "FAIL: firewall_pre: the invalid tun_name was refused without a warning"
|
||||
FAILED=1
|
||||
fi
|
||||
# Whether a rule was emitted is asserted in the driver (bad-tun expects no
|
||||
# forward and no input fragment). Grepping out.txt for the payload would be a
|
||||
# false positive: the warning quotes the rejected value.
|
||||
unset TUN_NAME
|
||||
|
||||
# --- a valid tun_name still reaches the ruleset ------------------------
|
||||
# The driver asserts the exact rules; this only confirms the guard did not
|
||||
# disable the feature wholesale.
|
||||
ROUTING_MODE='bypass_mainland_china' PROXY_MODE='tun' MAIN_NODE='urltest' SERVER_ENABLED='0'
|
||||
TUN_NAME='singtun9'
|
||||
seed good-tun
|
||||
run_scenario good-tun
|
||||
unset TUN_NAME
|
||||
|
||||
# --- scenario: an enabled server with a valid port --------------------
|
||||
ROUTING_MODE='bypass_mainland_china' PROXY_MODE='redirect_tproxy' MAIN_NODE='urltest' SERVER_ENABLED='1'
|
||||
seed server "config server 'srv_ok'
|
||||
option enabled '1'
|
||||
option firewall '1'
|
||||
option port '443'"
|
||||
run_scenario server
|
||||
|
||||
# --- scenario: an explicit tcp network narrows l4proto ----------------
|
||||
ROUTING_MODE='bypass_mainland_china' PROXY_MODE='redirect_tproxy' MAIN_NODE='urltest' SERVER_ENABLED='1'
|
||||
seed server-network "config server 'srv_tcp'
|
||||
option enabled '1'
|
||||
option firewall '1'
|
||||
option port '8080'
|
||||
option network 'tcp'"
|
||||
run_scenario server-network
|
||||
|
||||
# --- scenario: invalid port is skipped, not interpolated --------------
|
||||
ROUTING_MODE='bypass_mainland_china' PROXY_MODE='redirect_tproxy' MAIN_NODE='urltest' SERVER_ENABLED='1'
|
||||
seed invalid-port "config server 'srv_bad'
|
||||
option enabled '1'
|
||||
option firewall '1'
|
||||
option port 'not-a-port'"
|
||||
run_scenario invalid-port
|
||||
if ! grep -q 'WARN: skipping server srv_bad: invalid port' "$WORK/invalid-port/out.txt"; then
|
||||
echo "FAIL: firewall_pre: the invalid port was skipped without a warning"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
# --- scenario: invalid network is skipped, not interpolated -----------
|
||||
ROUTING_MODE='bypass_mainland_china' PROXY_MODE='redirect_tproxy' MAIN_NODE='urltest' SERVER_ENABLED='1'
|
||||
seed invalid-network "config server 'srv_badnet'
|
||||
option enabled '1'
|
||||
option firewall '1'
|
||||
option port '443'
|
||||
option network 'sctp'"
|
||||
run_scenario invalid-network
|
||||
if ! grep -q 'WARN: skipping server srv_badnet: invalid network' "$WORK/invalid-network/out.txt"; then
|
||||
echo "FAIL: firewall_pre: the invalid network was skipped without a warning"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
# --- scenario: a hostile section name is refused, not interpolated -----
|
||||
# firewall_pre.uc interpolates the section name into the nft comment, and that
|
||||
# guard cannot be exercised through a config *file*: libuci accepts only
|
||||
# [A-Za-z0-9_] in a section name, so a name carrying `"`/`}` - the only kind
|
||||
# that can close the comment - never survives the parser ("invalid character in
|
||||
# name field" on load, "Invalid argument" on `uci set`). The guard is the
|
||||
# second line of defense for any path that hands the script a name libuci never
|
||||
# validated, so this stage supplies one directly: the copy imports a cursor
|
||||
# stub instead of libuci and yields the name verbatim.
|
||||
#
|
||||
# The stub reads the name from <sandbox>/section-name, which lets the same
|
||||
# stage cover both halves: a name libuci would accept still gets its rule (so
|
||||
# the stub really does reach the emit path and the refusal below is the guard,
|
||||
# not a stub that emits nothing), and a hostile one is skipped.
|
||||
#
|
||||
# Grep for the warning, not the payload: the warning quotes the rejected name,
|
||||
# so a payload grep would match even when the guard worked - the same false
|
||||
# positive the bad-tun scenario calls out.
|
||||
|
||||
MOCK_STAGE="$WORK/mock-stage"
|
||||
mkdir -p "$MOCK_STAGE"
|
||||
cp "$ROOT/tests/ucode/mocks/homeproxy_firewall.uc" "$MOCK_STAGE/homeproxy.uc"
|
||||
cp "$ROOT/tests/ucode/test_firewall_pre.uc" "$MOCK_STAGE/"
|
||||
|
||||
# The cursor stub. It replaces libuci for this stage only; print / writefile and
|
||||
# the rest of the runtime stay real.
|
||||
cat > "$MOCK_STAGE/test_uci.uc" <<'STUB'
|
||||
/* Cursor stub for the section-name scenario in tests/ucode/test_firewall_pre.sh.
|
||||
* It yields a single `server` section whose name comes verbatim from
|
||||
* <sandbox>/section-name - including names libuci refuses, so the firewall_pre
|
||||
* guard is the only thing between the name and the nft comment. Everything
|
||||
* else matches the file-based fixture (config / infra / server globals). */
|
||||
import { readfile } from 'fs';
|
||||
|
||||
export function cursor(dir) {
|
||||
const name = trim(readfile(dir + '/section-name'));
|
||||
const globals = {
|
||||
config: { routing_mode: 'bypass_mainland_china', proxy_mode: 'redirect_tproxy', main_node: 'urltest' },
|
||||
infra: { tun_name: 'singtun0' },
|
||||
server: { enabled: '1' }
|
||||
};
|
||||
|
||||
function load() {
|
||||
return true;
|
||||
}
|
||||
|
||||
function get(cfg, section, option) {
|
||||
const entry = globals[section];
|
||||
|
||||
return entry ? entry[option] : null;
|
||||
}
|
||||
|
||||
function foreach(cfg, type, cb) {
|
||||
if (type === 'server')
|
||||
cb({ '.name': name, enabled: '1', firewall: '1', port: '443' });
|
||||
}
|
||||
|
||||
return { load: load, get: get, foreach: foreach };
|
||||
};
|
||||
STUB
|
||||
|
||||
sed -e '1{/^#!\/usr\/bin\/ucode$/d;}' \
|
||||
-e "s|^import { cursor } from 'uci';\$|import { cursor } from './test_uci.uc';|" \
|
||||
-e 's|^const uci = cursor();$|const uci = cursor(ARGV[0]);|' \
|
||||
"$ROOT/root/etc/homeproxy/scripts/firewall_pre.uc" > "$MOCK_STAGE/firewall_pre.uc"
|
||||
|
||||
# Same hard guards as above: a no-op sed must not silently fall back to libuci
|
||||
# (the hostile name would vanish and the scenario would pass for the wrong
|
||||
# reason) or to the live /etc/config.
|
||||
if ! grep -q "from './test_uci.uc'" "$MOCK_STAGE/firewall_pre.uc"; then
|
||||
echo "FAIL: firewall_pre: could not redirect the cursor to the test stub"
|
||||
echo " (the \"import { cursor } from 'uci';\" anchor no longer matches)"
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q 'cursor(ARGV\[0\])' "$MOCK_STAGE/firewall_pre.uc"; then
|
||||
echo "FAIL: firewall_pre: could not redirect the UCI cursor"
|
||||
echo " (the 'const uci = cursor();' anchor no longer matches)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
run_mock_scenario() {
|
||||
scenario="$1"
|
||||
name="$2"
|
||||
mkdir -p "$WORK/$scenario/sandbox" "$WORK/$scenario/run"
|
||||
printf '%s' "$name" > "$WORK/$scenario/sandbox/section-name"
|
||||
if ( cd "$MOCK_STAGE" && ucode -L "$MOCK_STAGE" \
|
||||
test_firewall_pre.uc \
|
||||
"$WORK/$scenario/sandbox" \
|
||||
"$WORK/$scenario/run" \
|
||||
"$scenario" > "$WORK/$scenario/out.txt" 2>&1 ); then
|
||||
cat "$WORK/$scenario/out.txt"
|
||||
else
|
||||
echo "FAIL: firewall_pre scenario '$scenario'"
|
||||
cat "$WORK/$scenario/out.txt"
|
||||
FAILED=1
|
||||
fi
|
||||
}
|
||||
|
||||
run_mock_scenario mock-good-section-name 'srv_ok_mock'
|
||||
run_mock_scenario invalid-section-name 'srv" } ; chain evil { type filter hook prerouting priority 0; policy accept; }'
|
||||
if ! grep -q 'WARN: skipping server .*: invalid section name' "$WORK/invalid-section-name/out.txt"; then
|
||||
echo "FAIL: firewall_pre: the hostile section name was not refused with a warning"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
# --- scenario: firewall='0' opts a server out entirely ----------------
|
||||
ROUTING_MODE='bypass_mainland_china' PROXY_MODE='redirect_tproxy' MAIN_NODE='urltest' SERVER_ENABLED='1'
|
||||
seed firewall-off "config server 'srv_manual'
|
||||
option enabled '1'
|
||||
option firewall '0'
|
||||
option port '443'"
|
||||
run_scenario firewall-off
|
||||
|
||||
# --- scenario: tun and server rules coexist ---------------------------
|
||||
ROUTING_MODE='bypass_mainland_china' PROXY_MODE='tun' MAIN_NODE='urltest' SERVER_ENABLED='1'
|
||||
seed both "config server 'srv_ok'
|
||||
option enabled '1'
|
||||
option firewall '1'
|
||||
option port '443'"
|
||||
run_scenario both
|
||||
|
||||
[ "$FAILED" -eq 0 ] && echo "PASS: firewall_pre behaviour tests"
|
||||
|
||||
exit $FAILED
|
||||
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/ucode
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Driver for tests/ucode/test_firewall_pre.sh.
|
||||
*
|
||||
* firewall_pre.uc is the fw4 pre-rules generator: it reads UCI and
|
||||
* writes the two nft fragments (fw4_forward.nft / fw4_input.nft)
|
||||
* that firewall_post.ut then includes. It has real branching - the
|
||||
* tun accept pair, the per-server accept rules, and two validation
|
||||
* guards that skip a malformed server rather than emitting a broken
|
||||
* nft statement - and none of it had a test.
|
||||
*
|
||||
* The driver imports the (staged, cursor-redirected) script, then
|
||||
* asserts on the generated fragments. Each scenario is a separate
|
||||
* import in a fresh process, because the module body runs once per
|
||||
* process; the shell wrapper drives the scenarios.
|
||||
*
|
||||
* Invoked as: ucode -L <stage> test_firewall_pre.uc <sandbox> <run-dir> <scenario>
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { access, readfile } from 'fs';
|
||||
|
||||
global.HP_FW_RUN_DIR = ARGV[1];
|
||||
import 'firewall_pre';
|
||||
|
||||
let failures = 0,
|
||||
checks = 0;
|
||||
|
||||
function expect(name, actual, want) {
|
||||
checks++;
|
||||
if (sprintf('%J', actual) !== sprintf('%J', want)) {
|
||||
printf('FAIL %s: expected %J, got %J\n', name, want, actual);
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
|
||||
const forward_file = ARGV[1] + '/fw4_forward.nft';
|
||||
const input_file = ARGV[1] + '/fw4_input.nft';
|
||||
|
||||
const forward = access(forward_file) ? trim(readfile(forward_file)) : null;
|
||||
const input = access(input_file) ? trim(readfile(input_file)) : null;
|
||||
|
||||
switch (ARGV[2]) {
|
||||
case 'tun':
|
||||
/* tun_mode routing: forward accepts traffic leaving the tun, input
|
||||
* accepts traffic arriving on it. */
|
||||
expect('tun forward', forward,
|
||||
'oifname singtun0 counter accept comment "!homeproxy: accept tun forward"');
|
||||
expect('tun input', input,
|
||||
'iifname singtun0 counter accept comment "!homeproxy: accept tun input"');
|
||||
break;
|
||||
|
||||
case 'no-tun':
|
||||
/* proxy_mode is tun but the outbound node is 'nil', so no tun rules
|
||||
* are emitted at all. */
|
||||
expect('no-tun forward', forward, null);
|
||||
expect('no-tun input', input, null);
|
||||
break;
|
||||
|
||||
case 'bad-tun':
|
||||
/* A tun_name that is not a valid interface name must not reach the
|
||||
* ruleset: the value is interpolated into an nft file that fw4 loads as
|
||||
* root, and `;`/`}` need no newline to break out of the rule. */
|
||||
expect('bad-tun forward', forward, null);
|
||||
expect('bad-tun input', input, null);
|
||||
break;
|
||||
|
||||
case 'good-tun':
|
||||
/* The same path with a valid name still emits the pair, so the guard
|
||||
* above cannot pass by disabling the feature. */
|
||||
expect('good-tun forward', forward,
|
||||
'oifname singtun9 counter accept comment "!homeproxy: accept tun forward"');
|
||||
expect('good-tun input', input,
|
||||
'iifname singtun9 counter accept comment "!homeproxy: accept tun input"');
|
||||
break;
|
||||
|
||||
case 'server':
|
||||
/* server enabled + firewall='1' + a valid port: one accept rule per
|
||||
* server section, keyed on the destination port. */
|
||||
expect('server input', input,
|
||||
'meta l4proto { tcp, udp } th dport 443 counter accept comment "!homeproxy: accept server srv_ok"');
|
||||
break;
|
||||
|
||||
case 'server-network':
|
||||
/* an explicit network narrows the l4proto match. */
|
||||
expect('server-network input', input,
|
||||
'meta l4proto tcp th dport 8080 counter accept comment "!homeproxy: accept server srv_tcp"');
|
||||
break;
|
||||
|
||||
case 'invalid-port':
|
||||
/* a non-numeric port would emit `th dport not-a-port` - broken nft.
|
||||
* The guard skips the server instead, and says why on stdout. */
|
||||
expect('invalid-port input', input, null);
|
||||
break;
|
||||
|
||||
case 'invalid-network':
|
||||
/* a network outside {tcp, udp, {tcp,udp}} would be interpolated
|
||||
* straight into the nft statement. The guard skips it. */
|
||||
expect('invalid-network input', input, null);
|
||||
break;
|
||||
|
||||
case 'invalid-section-name':
|
||||
/* The section name lands inside an nft comment, in a file fw4 loads as
|
||||
* root, so a name containing a closing quote would end the comment and
|
||||
* turn the rest of the name into nft syntax. libuci refuses such a name
|
||||
* before it can reach this loop (see the shell wrapper), so this case is
|
||||
* driven through a cursor stub that yields it verbatim: the server must
|
||||
* be skipped, not interpolated. */
|
||||
expect('invalid-section-name input', input, null);
|
||||
break;
|
||||
|
||||
case 'mock-good-section-name':
|
||||
/* The same stub with a name libuci would accept: the rule is emitted,
|
||||
* which is what makes the `null` above the guard's doing rather than the
|
||||
* stub never reaching the emit path. */
|
||||
expect('mock-good-section-name input', input,
|
||||
'meta l4proto { tcp, udp } th dport 443 counter accept comment "!homeproxy: accept server srv_ok_mock"');
|
||||
break;
|
||||
|
||||
case 'firewall-off':
|
||||
/* firewall='0' means the user opted this server out of the automatic
|
||||
* rule; nothing is emitted. */
|
||||
expect('firewall-off input', input, null);
|
||||
break;
|
||||
|
||||
case 'both':
|
||||
/* tun + server together: both fragments exist, each with its own
|
||||
* statements. */
|
||||
expect('both forward', forward,
|
||||
'oifname singtun0 counter accept comment "!homeproxy: accept tun forward"');
|
||||
expect('both input has tun', match(input, /iifname singtun0 counter accept/) != null, true);
|
||||
expect('both input has server', match(input, /th dport 443 counter accept/) != null, true);
|
||||
break;
|
||||
|
||||
default:
|
||||
printf('FAIL: unknown scenario %s\n', ARGV[2]);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
if (failures > 0)
|
||||
printf('FAIL: %s: %d/%d checks failed\n', ARGV[2], failures, checks);
|
||||
else
|
||||
printf('PASS: %s: %d checks\n', ARGV[2], checks);
|
||||
|
||||
exit(failures > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,189 @@
|
||||
#!/bin/sh
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# Render firewall_post.ut and verify the homeproxy fw4 objects survive as real
|
||||
# nft statements.
|
||||
#
|
||||
# The template opens with `{%-`, which trims the whitespace in front of it: any
|
||||
# text placed above that tag loses its trailing newline and is glued onto the
|
||||
# first generated statement, turning it into a comment. That silently dropped
|
||||
# every homeproxy chain/set from the fw4 ruleset once, so keep this check.
|
||||
#
|
||||
# Two layers, because the render needs the real `fw4` ucode module which only
|
||||
# exists on a device (OpenWrt's firewall4 package ships /usr/share/ucode/fw4.uc):
|
||||
#
|
||||
# 1. a source-level assertion, always run, that the glue condition itself
|
||||
# cannot come back - `{%-` must be the first thing after the shebang, so
|
||||
# there is no preceding text whose newline could be trimmed;
|
||||
# 2. the render + structural assertions, run whenever `fw4` is resolvable,
|
||||
# skipped with an explicit NOT RUN otherwise. A stub fw4 would make this
|
||||
# layer run everywhere, but it would also mean asserting on a ruleset the
|
||||
# real fw4 never produced, so the skip is kept and made *enforceable*
|
||||
# instead: HP_REQUIRE_FW4=1 turns it into a failure. tests/run.sh sets that
|
||||
# in its ssh branch, because a target always has firewall4 - so the one
|
||||
# environment that can run this layer must run it, and a target that
|
||||
# somehow cannot is a failure rather than a quiet NOT RUN.
|
||||
#
|
||||
# `utpl` is not the blocker: it ships with ucode, which
|
||||
# tests/toolchain/build-ucode-linux.sh builds. The missing piece is
|
||||
# firewall4's /usr/share/ucode/fw4.uc.
|
||||
#
|
||||
# Usage: sh tests/ucode/test_firewall_template.sh <repo-root> [work-dir]
|
||||
|
||||
ROOT="${1:-.}"
|
||||
ROOT="$(cd "$ROOT" && pwd)"
|
||||
FAILED=0
|
||||
|
||||
TEMPLATE_SRC="$ROOT/root/etc/homeproxy/scripts/firewall_post.ut"
|
||||
|
||||
# --- layer 1: the glue condition cannot come back ------------------------
|
||||
# `{%-` trims the whitespace before it, so anything other than the shebang
|
||||
# ahead of the tag ends up glued onto the first rendered line. Read the tag
|
||||
# position directly instead of trusting a comment.
|
||||
first_tag_line="$(grep -n '{%-' "$TEMPLATE_SRC" | head -1 | cut -d: -f1)"
|
||||
|
||||
if [ -z "$first_tag_line" ]; then
|
||||
echo "FAIL: firewall_post.ut has no '{%-' template tag"
|
||||
FAILED=1
|
||||
else
|
||||
# Lines 1..(tag-1) must be blank or the shebang.
|
||||
bad="$(sed -n "1,$((first_tag_line - 1))p" "$TEMPLATE_SRC" |
|
||||
grep -nvE '^[[:space:]]*$|^#!/' || true)"
|
||||
if [ -n "$bad" ]; then
|
||||
echo "FAIL: text precedes the '{%-' tag and will be glued onto the first statement"
|
||||
echo "$bad" | head -3
|
||||
FAILED=1
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- R-08: the gfwlist gate follows the dnsmasq nftset capability ----------
|
||||
# The gate compares the destination against the set dnsmasq fills with
|
||||
# `nftset=`. On a dnsmasq built without HAVE_NFTSET that set stays empty and
|
||||
# `ip daddr != @<empty set>` is true for every packet, so gfwlist mode would
|
||||
# silently route everything direct. The template therefore reads the
|
||||
# capability from resources/.dnsmasq-nftset - written by init.d before the
|
||||
# render - and must omit the gate when it says "no".
|
||||
#
|
||||
# This is asserted at the source level, not by rendering gfwlist mode: the
|
||||
# renderer runs with the device's own UCI (routing_mode = bypass_mainland_china
|
||||
# on any normal router, including this suite's host), so the gfwlist branch is
|
||||
# not reachable from here at all. What is checkable is that every gate is
|
||||
# wrapped and that the helper reads the marker the way init.d writes it.
|
||||
# The declaration `{% if (routing_mode === 'gfwlist'): %}` in front of the set
|
||||
# is data, not a gate; the gates are the same condition with a trailing colon.
|
||||
GATED="$(grep -c "routing_mode === 'gfwlist' && dnsmasq_nftset()" "$TEMPLATE_SRC")"
|
||||
UNGATED="$(grep -c "routing_mode === 'gfwlist'):" "$TEMPLATE_SRC")"
|
||||
|
||||
if [ "$GATED" -lt 4 ]; then
|
||||
echo "FAIL: only $GATED gfwlist gates carry the nftset capability check (expected 4:"
|
||||
echo " the LAN redirect, the UDP tproxy, the local output and the include chain)"
|
||||
FAILED=1
|
||||
fi
|
||||
# Exactly one ungated condition is expected: the one that declares the set the
|
||||
# gates match against. Every additional one is a gate, and a gate over an empty
|
||||
# set is true for every packet.
|
||||
if [ "$UNGATED" -ne 1 ]; then
|
||||
echo "FAIL: $UNGATED ungated 'routing_mode === 'gfwlist'' conditions in the template"
|
||||
echo " (exactly 1 is expected - the set declaration; the rest are gates)"
|
||||
FAILED=1
|
||||
fi
|
||||
if ! grep -q "resources_dir + '/.dnsmasq-nftset'" "$TEMPLATE_SRC"; then
|
||||
echo "FAIL: the template does not read resources/.dnsmasq-nftset, the marker init.d writes"
|
||||
FAILED=1
|
||||
fi
|
||||
if ! grep -q '\.dnsmasq-nftset' "$ROOT/root/etc/init.d/homeproxy"; then
|
||||
echo "FAIL: init.d no longer records the dnsmasq nftset capability for the template"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
# --- layer 2: the render -------------------------------------------------
|
||||
# The render output and its stderr live beside the staged template, never at a
|
||||
# fixed /tmp path. A caller that passes a work dir (tests/ucode/run.sh hands
|
||||
# every sub-suite a directory under its own $WORK) gets the pieces under it;
|
||||
# with no argument each run gets its own mktemp -d instead. Either way no two
|
||||
# runs share a path, and the whole stage goes away on every exit path - this
|
||||
# used to leak a directory per run (50 of them had accumulated on the test
|
||||
# device), which is both untidy and a determinism problem.
|
||||
if [ -n "${2:-}" ]; then
|
||||
STAGE="$2/firewall_template"
|
||||
rm -rf "$STAGE"
|
||||
mkdir -p "$STAGE"
|
||||
OWN_STAGE=0
|
||||
else
|
||||
STAGE="$(mktemp -d "${TMPDIR:-/tmp}/hp-fwtpl.XXXXXX")"
|
||||
OWN_STAGE=1
|
||||
fi
|
||||
STAGED="$STAGE/firewall_post.ut"
|
||||
OUT="$STAGE/rendered.nft"
|
||||
ERR="$STAGE/render.err"
|
||||
trap '[ "$OWN_STAGE" = 1 ] && rm -rf "$STAGE"' EXIT INT TERM
|
||||
|
||||
# Probe with ucode, not utpl: `utpl -e` is not an eval flag, it renders the
|
||||
# argument as template text and always succeeds.
|
||||
if ! ucode -e 'require("fw4");' > "/dev/null" 2>&1; then
|
||||
echo "NOT RUN: the 'fw4' ucode module is not available (device-only), render skipped"
|
||||
|
||||
# A skipped layer must not be able to hide a broken template. Firewall4 is
|
||||
# always installed where homeproxy runs, so the on-target suite sets
|
||||
# HP_REQUIRE_FW4=1 (tests/run.sh does it in the ssh branch) and a skip there
|
||||
# is a failure: it means the render stopped being exercised in the one
|
||||
# environment that can exercise it. `utpl` itself is not the blocker - it
|
||||
# ships with ucode, which the toolchain builds - the missing piece is
|
||||
# firewall4's /usr/share/ucode/fw4.uc, and stubbing that would mean
|
||||
# asserting on a ruleset the real fw4 never produced.
|
||||
if [ "${HP_REQUIRE_FW4:-0}" = "1" ]; then
|
||||
echo "FAIL: HP_REQUIRE_FW4=1 but 'fw4' is not resolvable, so the render"
|
||||
echo " layer cannot run here and the template is unverified"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
[ "$FAILED" -eq 0 ] && echo "PASS: firewall_post.ut keeps '{%-' directly after the shebang"
|
||||
exit $FAILED
|
||||
fi
|
||||
|
||||
# The template is written for a device: it imports homeproxy.uc through
|
||||
# /etc/homeproxy/scripts/ and reads /etc/homeproxy/resources. Neither exists
|
||||
# off-target, so render a staged copy with those prefixes rewritten to the
|
||||
# checkout - the same trick run.sh uses for the absolute import in
|
||||
# root/usr/share/rpcd/ucode/luci.homeproxy.
|
||||
sed -e "s#'/etc/homeproxy/#'$ROOT/root/etc/homeproxy/#g" "$TEMPLATE_SRC" > "$STAGED"
|
||||
|
||||
# Hard guard: if the prefix ever changes, the sed silently no-ops and the
|
||||
# render fails for an unrelated reason, which would look like a template
|
||||
# regression. Refuse to run instead.
|
||||
if grep -q "'/etc/homeproxy/" "$STAGED"; then
|
||||
echo "FAIL: firewall_post.ut: could not rewrite the device paths"
|
||||
echo " (the '/etc/homeproxy/' prefix anchor no longer matches)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! utpl -S "$STAGED" > "$OUT" 2> "$ERR"; then
|
||||
echo "FAIL: could not render firewall_post.ut"
|
||||
head -5 "$ERR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Declared unconditionally by the template, so they must always be present as
|
||||
# standalone statements.
|
||||
for name in homeproxy_local_addr_v4 homeproxy_wan_proxy_addr_v4; do
|
||||
if ! grep -q "^set $name {" "$OUT"; then
|
||||
echo "FAIL: 'set $name {' is missing from the rendered firewall template"
|
||||
grep -n "$name" "$OUT" | head -2
|
||||
FAILED=1
|
||||
fi
|
||||
done
|
||||
|
||||
# A statement glued onto the preceding comment looks like
|
||||
# "# <comment text>.set homeproxy_x {" -- exactly the failure mode above.
|
||||
if grep -nE "^#[^!].*homeproxy_[a-z0-9_]+ \{" "$OUT" > "/dev/null"; then
|
||||
echo "FAIL: an nft statement is glued onto a comment line"
|
||||
grep -nE "^#[^!].*homeproxy_[a-z0-9_]+ \{" "$OUT" | head -2
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
if [ "$FAILED" -eq 0 ]; then
|
||||
echo "PASS: firewall_post.ut renders homeproxy objects as standalone nft statements"
|
||||
echo "PASS: the gfwlist gate follows the dnsmasq nftset capability"
|
||||
fi
|
||||
|
||||
exit $FAILED
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/bin/sh
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# Direct unit tests for the firewall field validators
|
||||
# (root/etc/homeproxy/scripts/firewall_utils.uc).
|
||||
#
|
||||
# Why a separate file rather than extending test_firewall_template.sh:
|
||||
# the rendering test needs the device-only `fw4` ucode module, so on a
|
||||
# development host it only runs the source-level glue precondition; it
|
||||
# cannot exercise the validators themselves. The validators are pure
|
||||
# functions, so a self-contained ucode test runs anywhere ucode runs.
|
||||
# Together they are the report's H1: the renderer cannot regress
|
||||
# silently because the validators are independently pinned here.
|
||||
#
|
||||
# Usage: sh tests/ucode/test_firewall_validators.sh <repo-root> [work-dir]
|
||||
|
||||
ROOT="${1:-.}"
|
||||
WORK="${2:-/tmp/hp-firewall-validators-test}"
|
||||
|
||||
ROOT="$(cd "$ROOT" && pwd)"
|
||||
|
||||
if ! command -v ucode > "/dev/null" 2>&1; then
|
||||
echo "NOT RUN: ucode is not on PATH."
|
||||
echo " Build the testbed toolchain first (tests/toolchain/build-ucode-linux.sh)."
|
||||
exit 2
|
||||
fi
|
||||
|
||||
rm -rf "$WORK"
|
||||
mkdir -p "$WORK"
|
||||
|
||||
# The validators import `isEmpty` from homeproxy.uc. Stage a copy that
|
||||
# just exports it - the validators under test are the only thing this
|
||||
# process exercises, so pulling in the full homeproxy.uc (with its
|
||||
# validate_data / popen / luci.* imports) would break the unit test on
|
||||
# a host that lacks /sbin/validate_data.
|
||||
cat > "$WORK/homeproxy.uc" <<'HPEOF'
|
||||
export function isEmpty(res) {
|
||||
return !res || res === 'nil' || (type(res) in ['array', 'object'] && length(res) === 0);
|
||||
};
|
||||
HPEOF
|
||||
|
||||
cp "$ROOT/root/etc/homeproxy/scripts/firewall_utils.uc" "$WORK/firewall_utils.uc"
|
||||
cp "$ROOT/tests/ucode/test_firewall_validators.uc" "$WORK/test_firewall_validators.uc"
|
||||
|
||||
if ( cd "$WORK" && ucode test_firewall_validators.uc ); then
|
||||
echo "PASS: firewall field validators drop invalid entries"
|
||||
exit 0
|
||||
else
|
||||
echo "FAIL: firewall field validators"
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,216 @@
|
||||
#!/usr/bin/ucode
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Direct unit tests for the field validators that firewall_post.ut routes
|
||||
* every UCI-derived value through (the H1 fix from the 2026-09-16 review).
|
||||
*
|
||||
* What this catches that the rendering test cannot:
|
||||
*
|
||||
* The renderer (tests/ucode/test_firewall_template.sh) needs the device-only
|
||||
* `fw4` ucode module, so on a development host it only runs the source-level
|
||||
* precondition check (the `{%-` glue guard). The validators themselves are
|
||||
* pure functions: they take a string or a list and return either an nft
|
||||
* anonymous-set spelling or null. This file exercises them directly, so a
|
||||
* regex tightening, an accidental inversion of the predicate, or a missing
|
||||
* drop branch shows up as a unit-test failure here even when the renderer
|
||||
* layer is offline.
|
||||
*
|
||||
* What the tests cover:
|
||||
*
|
||||
* isValidIPv4 - accept dotted-quad with optional /CIDR, reject anything
|
||||
* that is not (e.g. "1.2.3", "1.2.3.4.5", "1.2.3.4 ", "abc").
|
||||
* isValidMAC - accept six colon-separated hex octets (case-insensitive),
|
||||
* reject anything else ("GG" octet, "1:2:3:4:5", "1:2:3:4:5:6:7").
|
||||
* isValidIface - accept alnum + . _ -, reject whitespace, brackets, slashes,
|
||||
* quotes, semicolons, the obvious injection characters.
|
||||
* isValidPort - accept 1..65535, reject 0, negatives, decimals, alpha.
|
||||
*
|
||||
* ipv4_to_nftarr / mac_to_nftarr / iface_to_nftarr - the canonical case
|
||||
* (all entries valid), the all-poison case (every entry
|
||||
* dropped, returns null), and the mixed case (poison
|
||||
* entries stripped, valid entries kept).
|
||||
*
|
||||
* ports_to_nftarr - the comma-separated UCI string the routing_port option
|
||||
* actually ships: a clean list, an all-poison list, and
|
||||
* the report's example ("22,7890; reject,443,foo" ->
|
||||
* "{ 22, 443 }").
|
||||
*
|
||||
* Reverse-proving: removing any drop branch - or relaxing a regex - makes
|
||||
* the matching test fail, the way guard 11 in tests/arch-guard.sh would
|
||||
* make a future regression in firewall_post.ut itself fail. The two together
|
||||
* are the report's "fix + reverse-verify" loop.
|
||||
*
|
||||
* Run from tests/ucode/test_firewall_validators.sh, which stages
|
||||
* firewall_utils.uc (and the homeproxy.uc bits the validators depend on)
|
||||
* in $WORK.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import {
|
||||
isValidIPv4, ipv4_to_nftarr,
|
||||
isValidMAC, mac_to_nftarr,
|
||||
isValidIface, iface_to_nftarr,
|
||||
isValidPort, ports_to_nftarr
|
||||
} from 'firewall_utils';
|
||||
|
||||
let failures = 0, checks = 0;
|
||||
|
||||
function expect(name, actual, want) {
|
||||
checks++;
|
||||
if (sprintf('%J', actual) !== sprintf('%J', want)) {
|
||||
printf('FAIL %s: expected %J, got %J\n', name, want, actual);
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
|
||||
/* isValidIPv4 ------------------------------------------------------------ */
|
||||
{
|
||||
expect('isValidIPv4 accept 1.2.3.4', isValidIPv4('1.2.3.4'), true);
|
||||
expect('isValidIPv4 accept 255.255.255.255', isValidIPv4('255.255.255.255'), true);
|
||||
expect('isValidIPv4 accept 10.0.0.0/8', isValidIPv4('10.0.0.0/8'), true);
|
||||
expect('isValidIPv4 accept 192.168.1.0/24', isValidIPv4('192.168.1.0/24'), true);
|
||||
expect('isValidIPv4 accept 0.0.0.0/0', isValidIPv4('0.0.0.0/0'), true);
|
||||
expect('isValidIPv4 reject 1.2.3', isValidIPv4('1.2.3'), false);
|
||||
expect('isValidIPv4 reject 1.2.3.4.5', isValidIPv4('1.2.3.4.5'), false);
|
||||
expect('isValidIPv4 reject "1.2.3.4 "', isValidIPv4('1.2.3.4 '), false);
|
||||
expect('isValidIPv4 reject " 1.2.3.4"', isValidIPv4(' 1.2.3.4'), false);
|
||||
expect('isValidIPv4 reject abc', isValidIPv4('abc'), false);
|
||||
expect('isValidIPv4 reject empty', isValidIPv4(''), false);
|
||||
expect('isValidIPv4 reject null', isValidIPv4(null), false);
|
||||
expect('isValidIPv4 reject number', isValidIPv4(123), false);
|
||||
/* The H1 injection: a value that closes a nft set. */
|
||||
expect('isValidIPv4 reject injection',
|
||||
isValidIPv4('1.2.3.4 } ; reject ; set x {'), false);
|
||||
}
|
||||
|
||||
/* isValidMAC ------------------------------------------------------------- */
|
||||
{
|
||||
expect('isValidMAC accept aa:bb:cc:dd:ee:ff', isValidMAC('aa:bb:cc:dd:ee:ff'), true);
|
||||
expect('isValidMAC accept AA:BB:CC:DD:EE:FF', isValidMAC('AA:BB:CC:DD:EE:FF'), true);
|
||||
expect('isValidMAC reject GG octet',
|
||||
isValidMAC('00:11:22:33:44:GG'), false);
|
||||
expect('isValidMAC reject too few octets',
|
||||
isValidMAC('aa:bb:cc:dd:ee'), false);
|
||||
expect('isValidMAC reject too many octets',
|
||||
isValidMAC('aa:bb:cc:dd:ee:ff:00'), false);
|
||||
expect('isValidMAC reject dash separator',
|
||||
isValidMAC('aa-bb-cc-dd-ee-ff'), false);
|
||||
expect('isValidMAC reject empty', isValidMAC(''), false);
|
||||
}
|
||||
|
||||
/* isValidIface ----------------------------------------------------------- */
|
||||
{
|
||||
expect('isValidIface accept eth0', isValidIface('eth0'), true);
|
||||
expect('isValidIface accept br-lan', isValidIface('br-lan'), true);
|
||||
expect('isValidIface accept eth0.10', isValidIface('eth0.10'), true);
|
||||
expect('isValidIface accept lo', isValidIface('lo'), true);
|
||||
expect('isValidIface reject space',
|
||||
isValidIface('eth 0'), false);
|
||||
expect('isValidIface reject slash',
|
||||
isValidIface('eth0/1'), false);
|
||||
expect('isValidIface reject semicolon',
|
||||
isValidIface('eth0;reject'), false);
|
||||
expect('isValidIface reject injection',
|
||||
isValidIface('eth0 } reject ; set x {'), false);
|
||||
}
|
||||
|
||||
/* isValidPort ------------------------------------------------------------ */
|
||||
{
|
||||
expect('isValidPort reject 0', isValidPort('0'), false);
|
||||
expect('isValidPort accept 22', isValidPort('22'), true);
|
||||
expect('isValidPort accept 65535', isValidPort('65535'), true);
|
||||
expect('isValidPort reject 65536', isValidPort('65536'), false);
|
||||
expect('isValidPort reject -1', isValidPort('-1'), false);
|
||||
expect('isValidPort reject 22.5', isValidPort('22.5'), false);
|
||||
expect('isValidPort reject abc', isValidPort('abc'), false);
|
||||
expect('isValidPort reject empty', isValidPort(''), false);
|
||||
expect('isValidPort reject injection',
|
||||
isValidPort('7890; reject'), false);
|
||||
}
|
||||
|
||||
/* ipv4_to_nftarr --------------------------------------------------------- */
|
||||
{
|
||||
/* Canonical case: every entry valid. */
|
||||
expect('ipv4_to_nftarr clean',
|
||||
ipv4_to_nftarr(['10.0.0.1', '192.168.1.0/24']),
|
||||
'{ 10.0.0.1, 192.168.1.0/24 }');
|
||||
/* All-poison: every entry dropped, returns null. */
|
||||
expect('ipv4_to_nftarr all-poison returns null',
|
||||
ipv4_to_nftarr(['1.2.3.4 } ; reject ; set x {',
|
||||
'not.an.ip', '5']),
|
||||
null);
|
||||
/* Mixed: poison entries stripped, valid kept. */
|
||||
expect('ipv4_to_nftarr mixed drops the poison',
|
||||
ipv4_to_nftarr(['10.0.0.1', '1.2.3.4 } ; reject ; set x {', '192.168.1.1']),
|
||||
'{ 10.0.0.1, 192.168.1.1 }');
|
||||
/* Empty list. */
|
||||
expect('ipv4_to_nftarr empty returns null',
|
||||
ipv4_to_nftarr([]), null);
|
||||
expect('ipv4_to_nftarr null returns null',
|
||||
ipv4_to_nftarr(null), null);
|
||||
/* Dedup. */
|
||||
expect('ipv4_to_nftarr dedups',
|
||||
ipv4_to_nftarr(['10.0.0.1', '10.0.0.1', '10.0.0.2']),
|
||||
'{ 10.0.0.1, 10.0.0.2 }');
|
||||
}
|
||||
|
||||
/* mac_to_nftarr ---------------------------------------------------------- */
|
||||
{
|
||||
expect('mac_to_nftarr clean',
|
||||
mac_to_nftarr(['aa:bb:cc:dd:ee:ff', '00:11:22:33:44:55']),
|
||||
'{ aa:bb:cc:dd:ee:ff, 00:11:22:33:44:55 }');
|
||||
expect('mac_to_nftarr all-poison returns null',
|
||||
mac_to_nftarr(['00:11:22:33:44:GG',
|
||||
'aa-bb-cc-dd-ee-ff',
|
||||
'too:short']),
|
||||
null);
|
||||
expect('mac_to_nftarr mixed drops the poison',
|
||||
mac_to_nftarr(['aa:bb:cc:dd:ee:ff', '00:11:22:33:44:GG', '11:22:33:44:55:66']),
|
||||
'{ aa:bb:cc:dd:ee:ff, 11:22:33:44:55:66 }');
|
||||
}
|
||||
|
||||
/* iface_to_nftarr -------------------------------------------------------- */
|
||||
{
|
||||
expect('iface_to_nftarr clean',
|
||||
iface_to_nftarr(['eth0', 'br-lan']),
|
||||
'{ eth0, br-lan }');
|
||||
expect('iface_to_nftarr all-poison returns null',
|
||||
iface_to_nftarr(['eth0 } reject ; set x {',
|
||||
'eth 0',
|
||||
'eth0/1']),
|
||||
null);
|
||||
expect('iface_to_nftarr mixed drops the poison',
|
||||
iface_to_nftarr(['eth0', 'eth0;reject', 'br-lan']),
|
||||
'{ eth0, br-lan }');
|
||||
}
|
||||
|
||||
/* ports_to_nftarr -------------------------------------------------------- */
|
||||
{
|
||||
/* The exact injection the report called out. */
|
||||
expect('ports_to_nftarr clean',
|
||||
ports_to_nftarr('22,53,80,443'),
|
||||
'{ 22, 53, 80, 443 }');
|
||||
expect('ports_to_nftarr all-poison returns null',
|
||||
ports_to_nftarr('abc,7890; reject,-1,65536'),
|
||||
null);
|
||||
expect('ports_to_nftarr mixed drops the poison',
|
||||
ports_to_nftarr('22,7890; reject,443,foo'),
|
||||
'{ 22, 443 }');
|
||||
expect('ports_to_nftarr empty returns null',
|
||||
ports_to_nftarr(''), null);
|
||||
expect('ports_to_nftarr handles whitespace',
|
||||
ports_to_nftarr(' 22 , 53 , 80 '),
|
||||
'{ 22, 53, 80 }');
|
||||
expect('ports_to_nftarr handles common defaults',
|
||||
ports_to_nftarr('22,53,80,143,443,465,587,853,873,993,995,8080,8443,9418'),
|
||||
'{ 22, 53, 80, 143, 443, 465, 587, 853, 873, 993, 995, 8080, 8443, 9418 }');
|
||||
}
|
||||
|
||||
if (failures > 0)
|
||||
printf('FAIL: %d/%d checks failed\n', failures, checks);
|
||||
else
|
||||
printf('PASS: %d checks\n', checks);
|
||||
|
||||
exit(failures > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/bin/sh
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# The fw4 chain/set inventory in scripts/fw4_names.sh must stay in sync with
|
||||
# the objects declared by scripts/firewall_post.ut: the first drives the
|
||||
# cleanup in init.d/homeproxy, the second creates them. A drift means either a
|
||||
# leftover object or a delete of something that never existed (which used to
|
||||
# abort the whole cleanup batch).
|
||||
#
|
||||
# Usage: sh tests/ucode/test_fw4_names.sh <repo-root>
|
||||
|
||||
ROOT="${1:-.}"
|
||||
ROOT="$(cd "$ROOT" && pwd)"
|
||||
|
||||
NAMES="$ROOT/root/etc/homeproxy/scripts/fw4_names.sh"
|
||||
POST="$ROOT/root/etc/homeproxy/scripts/firewall_post.ut"
|
||||
PRE="$ROOT/root/etc/homeproxy/scripts/firewall_pre.uc"
|
||||
|
||||
FAILED=0
|
||||
|
||||
# shellcheck source=/dev/null
|
||||
. "$NAMES"
|
||||
|
||||
check_group() {
|
||||
group="$1"
|
||||
list="$(printf '%s\n' $2 | tr '\n' ' ')"
|
||||
declared="$(printf '%s\n' $3 | tr '\n' ' ')"
|
||||
missing=""
|
||||
extra=""
|
||||
|
||||
for name in $declared; do
|
||||
case " $list " in
|
||||
*" $name "*) ;;
|
||||
*) missing="$missing $name" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
for name in $list; do
|
||||
case " $declared " in
|
||||
*" $name "*) ;;
|
||||
*) extra="$extra $name" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z "$missing$extra" ]; then
|
||||
echo "PASS: $group inventory matches firewall_post.ut"
|
||||
else
|
||||
[ -n "$missing" ] && echo "FAIL: $group declared in firewall_post.ut but missing from fw4_names.sh:$missing"
|
||||
[ -n "$extra" ] && echo "FAIL: $group listed in fw4_names.sh but not declared in firewall_post.ut:$extra"
|
||||
FAILED=1
|
||||
fi
|
||||
}
|
||||
|
||||
post_chains="$(grep -oE '^chain homeproxy_[a-z0-9_]+' "$POST" | awk '{print $2}' | sort -u)"
|
||||
post_sets="$(grep -oE '^set homeproxy_[a-z0-9_]+' "$POST" | awk '{print $2}' | sort -u)"
|
||||
|
||||
check_group "chain" "$HP_FW4_CHAINS" "$post_chains"
|
||||
check_group "set" "$HP_FW4_SETS" "$post_sets"
|
||||
|
||||
# firewall_pre.uc only writes forward/input accept rules; it must not own any
|
||||
# chain/set of its own.
|
||||
pre_refs="$(grep -oE 'homeproxy_[a-z0-9_]+' "$PRE" | sort -u)"
|
||||
if [ -n "$pre_refs" ]; then
|
||||
echo "NOTE: firewall_pre.uc references fw4 objects:$pre_refs"
|
||||
fi
|
||||
|
||||
exit $FAILED
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* generator/common.uc's tag helpers, driven with a hand-built domain model.
|
||||
*
|
||||
* The one that regressed: isDirectOutboundTag() is called with the tag the
|
||||
* generator *emits* (get_outbound() builds `cfg-<node>-out`), not with the
|
||||
* routing_node section name, and it looked the value up in both tables
|
||||
* unchanged. Every routing_node therefore answered "not direct", so
|
||||
* build_http_clients() kept a detour on a rule-set download in pure TUN mode -
|
||||
* where sing-box refuses to detour into an empty direct outbound and rejects
|
||||
* the whole configuration.
|
||||
*
|
||||
* Run by tests/ucode/run.sh from a staging directory that holds homeproxy.uc,
|
||||
* config/ and parser/.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { isDirectOutboundTag, get_outbound } from 'generator/common.uc';
|
||||
|
||||
let checks = 0, failures = 0;
|
||||
|
||||
function expect(name, actual, want) {
|
||||
checks++;
|
||||
if (actual !== want) {
|
||||
failures++;
|
||||
printf('FAIL %s: got %s, want %s\n', name, actual, want);
|
||||
}
|
||||
}
|
||||
|
||||
/* A domain model with just the tables the helpers walk. */
|
||||
const dm = {
|
||||
nodes: [
|
||||
{ id: 'n_direct', type: 'direct' },
|
||||
{ id: 'n_proxy', type: 'vless' },
|
||||
{ id: 'n_wg', type: 'wireguard' }
|
||||
],
|
||||
routing: {
|
||||
nodes: [
|
||||
{ name: 'rn_direct', node: 'n_direct' },
|
||||
{ name: 'rn_group', node: 'urltest' }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
expect('literal direct-out', isDirectOutboundTag('direct-out', dm), true);
|
||||
expect('literal block-out', isDirectOutboundTag('block-out', dm), false);
|
||||
expect('unknown tag', isDirectOutboundTag('cfg-n_missing-out', dm), false);
|
||||
|
||||
/* The emitted form, which is what the caller actually passes. */
|
||||
expect('direct node via cfg- tag', isDirectOutboundTag('cfg-n_direct-out', dm), true);
|
||||
expect('proxy node via cfg- tag', isDirectOutboundTag('cfg-n_proxy-out', dm), false);
|
||||
expect('wireguard node via cfg- tag', isDirectOutboundTag('cfg-n_wg-out', dm), false);
|
||||
|
||||
/* A routing_node that points at a direct node is direct; one that points at
|
||||
* the urltest group is a group, not a direct outbound. */
|
||||
expect('routing_node to direct node', isDirectOutboundTag('cfg-rn_direct-out', dm), true);
|
||||
expect('routing_node to urltest', isDirectOutboundTag('cfg-rn_group-out', dm), false);
|
||||
|
||||
/* The tag the helper is asked about has to be the tag the generator emits. */
|
||||
expect('get_outbound emits cfg- prefix', get_outbound('rn_direct', dm), 'cfg-n_direct-out');
|
||||
|
||||
printf('%d checks, %d failures\n', checks, failures);
|
||||
exit(failures ? 1 : 0);
|
||||
+643
@@ -0,0 +1,643 @@
|
||||
#!/bin/sh
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# Run generate_client.uc / generate_server.uc against the UCI fixtures and let
|
||||
# sing-box validate the result. The generators read /etc/config and write
|
||||
# /var/run, so this script materialises an isolated copy of them: homeproxy.uc
|
||||
# is rewritten to point HP_DIR/RUN_DIR at a scratch directory, and the uci
|
||||
# cursor is pointed at the fixture config.
|
||||
#
|
||||
# Stage PHASE 4: the generators are now split into generator/*.uc modules
|
||||
# and the production entry points (scripts/generate_client.uc and
|
||||
# scripts/generate_server.uc) are 10-line CLI shells that just load the UCI,
|
||||
# call the module's generate(), and run the atomic write + sing-box check.
|
||||
# The testbed no longer needs to sed-substitute a `__LOADER_DIR__` token
|
||||
# because that mechanism is gone - the staged scripts/ subtree is a regular
|
||||
# directory and `Loader.load()` takes the path as an argument.
|
||||
#
|
||||
# Usage: sh tests/ucode/test_generators.sh <repo-root> [work-dir]
|
||||
|
||||
ROOT="${1:-.}"
|
||||
WORK="${2:-/tmp/hp-generator-test}"
|
||||
|
||||
ROOT="$(cd "$ROOT" && pwd)"
|
||||
FAILED=0
|
||||
|
||||
# The local rule-set fixture has to live under /tmp/homeproxy_ (see below), so
|
||||
# it cannot sit inside $WORK; this is the per-run root that holds it instead.
|
||||
# Created lazily by the first case that needs one.
|
||||
RULESET_ROOT=""
|
||||
cleanup() {
|
||||
[ -n "$RULESET_ROOT" ] && rm -rf "$RULESET_ROOT"
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
run_case() {
|
||||
name="$1"
|
||||
fixture="$2"
|
||||
generator="$3"
|
||||
outfile="$4"
|
||||
# Optional: a sed expression applied to the staged fixture, for cases that
|
||||
# differ from the shared fixture by one UCI option. Cheaper and more
|
||||
# explicit than a near-copy fixture file, and the expression is visible at
|
||||
# the call site next to the assertion it serves.
|
||||
variation="${5:-}"
|
||||
dir="$WORK/$name"
|
||||
|
||||
rm -rf "$dir"
|
||||
mkdir -p "$dir/config" "$dir/run" "$dir/scripts" "$dir/scripts/config" "$dir/scripts/generator" "$dir/resources" "$dir/ruleset"
|
||||
: > "$dir/resources/direct_list.txt"
|
||||
: > "$dir/resources/proxy_list.txt"
|
||||
|
||||
if grep -q "__RULESET_DIR__" "$fixture"; then
|
||||
# The fixture needs a real local rule-set on disk. The path must
|
||||
# live under /tmp/homeproxy_* (validateHomeProxyPath() in
|
||||
# homeproxy.uc whitelists /etc/homeproxy/ and /tmp/homeproxy_
|
||||
# only, and the custom fixture exercises the local-rule-set
|
||||
# path whitelist gate introduced by the security patch).
|
||||
printf '%s' '{"version":1,"rules":[{"domain_suffix":["example.com"]}]}' > "$dir/ruleset/src.json"
|
||||
if ! sing-box rule-set compile "$dir/ruleset/src.json" -o "$dir/ruleset/test.srs"; then
|
||||
echo "FAIL: $name: could not compile the local rule-set fixture"
|
||||
FAILED=1
|
||||
return
|
||||
fi
|
||||
# Stage the ruleset under a /tmp/homeproxy_* directory so the
|
||||
# whitelist recognises the staging path. Production paths
|
||||
# typically live at /etc/homeproxy/ruleset/...
|
||||
#
|
||||
# The path cannot move under $WORK (validateHomeProxyPath() in
|
||||
# homeproxy.uc whitelists /etc/homeproxy/ and /tmp/homeproxy_
|
||||
# only), but it can still be per-run: mktemp gives each run its
|
||||
# own tree, and the trap removes it even when a case bails out
|
||||
# early. The old fixed /tmp/homeproxy_test_ruleset/$name leaked
|
||||
# one directory per run and let two concurrent runs overwrite
|
||||
# each other's compiled .srs.
|
||||
if [ -z "$RULESET_ROOT" ]; then
|
||||
RULESET_ROOT="$(mktemp -d /tmp/homeproxy_test_ruleset.XXXXXX)"
|
||||
fi
|
||||
HP_RULESET="$RULESET_ROOT/$name"
|
||||
mkdir -p "$HP_RULESET"
|
||||
cp "$dir/ruleset/test.srs" "$HP_RULESET/test.srs"
|
||||
sed "s#__RULESET_DIR__#$HP_RULESET#" "$fixture" > "$dir/config/homeproxy"
|
||||
else
|
||||
cp "$fixture" "$dir/config/homeproxy"
|
||||
fi
|
||||
|
||||
if [ -n "$variation" ]; then
|
||||
sed -e "$variation" "$dir/config/homeproxy" > "$dir/config/homeproxy.sed" \
|
||||
|| { echo "FAIL: $name: the fixture variation could not be applied"; FAILED=1; return; }
|
||||
if cmp -s "$dir/config/homeproxy" "$dir/config/homeproxy.sed"; then
|
||||
# A variation that matched nothing would run the same case twice
|
||||
# and report a pass for behaviour that was never exercised.
|
||||
echo "FAIL: $name: the fixture variation matched nothing ($variation)"
|
||||
FAILED=1
|
||||
return
|
||||
fi
|
||||
mv "$dir/config/homeproxy.sed" "$dir/config/homeproxy"
|
||||
fi
|
||||
|
||||
|
||||
# HP_VALIDATE_DATA lets a development host replace /sbin/validate_data
|
||||
# (see tests/README.md); on a target the production path is kept.
|
||||
#
|
||||
# UCICONFIG_DIR is the UCI confdir the Loader hands to cursor(). On a
|
||||
# target it is /etc/config; here it has to point at the fixture staged
|
||||
# below as $dir/config/homeproxy. Rewriting the constant is what keeps
|
||||
# the staging seam out of the source files - staging it as
|
||||
# HP_DIR + '/config' instead would silently diverge from production,
|
||||
# which is exactly the bug this constant replaced.
|
||||
VALIDATE_DATA="${HP_VALIDATE_DATA:-/sbin/validate_data}"
|
||||
sed -e "s#^export const HP_DIR = '/etc/homeproxy';#export const HP_DIR = '$dir';#" \
|
||||
-e "s#^export const RUN_DIR = '/var/run/homeproxy';#export const RUN_DIR = '$dir/run';#" \
|
||||
-e "s#^export const UCICONFIG_DIR = '/etc/config';#export const UCICONFIG_DIR = '$dir/config';#" \
|
||||
-e "s#/sbin/validate_data#${VALIDATE_DATA}#" \
|
||||
"$ROOT/root/etc/homeproxy/scripts/homeproxy.uc" > "$dir/scripts/homeproxy.uc"
|
||||
|
||||
# Stage the config/ subtree (Loader / Model / Adapter, imported via
|
||||
# the relative path "../config/*.uc" by the generator modules).
|
||||
cp "$ROOT/root/etc/homeproxy/scripts/config/loader.uc" "$dir/scripts/config/"
|
||||
cp "$ROOT/root/etc/homeproxy/scripts/config/model.uc" "$dir/scripts/config/"
|
||||
cp "$ROOT/root/etc/homeproxy/scripts/config/adapter.uc" "$dir/scripts/config/"
|
||||
# PR-02: config/loader.uc imports '../parser/mapping.uc', so the
|
||||
# parser tree has to be staged as a sibling of config/ or the
|
||||
# generator cannot even load the configuration.
|
||||
mkdir -p "$dir/scripts/parser"
|
||||
cp "$ROOT/root/etc/homeproxy/scripts/parser/"*.uc "$dir/scripts/parser/"
|
||||
|
||||
# Stage the generator/ subtree that PHASE 4 introduced. The CLI
|
||||
# shells (scripts/generate_*.uc) import from generator/; the
|
||||
# modules in turn import from common.uc, dns.uc, ... inside the
|
||||
# same directory.
|
||||
cp "$ROOT/root/etc/homeproxy/scripts/generator/"*.uc "$dir/scripts/generator/"
|
||||
|
||||
# Stage the CLI shells themselves. This is the entry point the
|
||||
# production init.d runs (`ucode -S generate_client.uc`); the
|
||||
# test exercises the same code path end-to-end, not a parallel
|
||||
# test-only driver.
|
||||
cp "$ROOT/root/etc/homeproxy/scripts/$generator" "$dir/scripts/$generator"
|
||||
|
||||
# No platform patching here on purpose. The suite used to rewrite
|
||||
# generator/client.uc's `routing_mark` to null on Darwin, because the
|
||||
# macOS sing-box rejects that Linux-only field - which meant the one
|
||||
# field the product needs on its target platform was silently deleted
|
||||
# before every test, and a regression in it could never be seen. The
|
||||
# off-target layer is Linux-only now (see tests/README.md); a host that
|
||||
# cannot validate the generated configuration says so instead of
|
||||
# weakening it.
|
||||
|
||||
# stderr is kept so a test can assert on warnings (e.g. a pruned urltest
|
||||
# candidate) as well as on the generated JSON.
|
||||
if ! ( cd "$dir/scripts" && ucode -L "$dir/scripts" "$generator" 2> "$dir/generate.err" ); then
|
||||
echo "FAIL: $name: $generator exited non-zero"
|
||||
head -5 "$dir/generate.err"
|
||||
cp "$dir/generate.err" "$WORK/$name.err" 2>"/dev/null"
|
||||
FAILED=1
|
||||
return
|
||||
fi
|
||||
|
||||
if [ ! -f "$dir/run/$outfile" ]; then
|
||||
echo "FAIL: $name: $outfile was not generated"
|
||||
FAILED=1
|
||||
return
|
||||
fi
|
||||
|
||||
if ! sing-box check --config "$dir/run/$outfile"; then
|
||||
echo "FAIL: $name: sing-box rejected the generated $outfile"
|
||||
FAILED=1
|
||||
return
|
||||
fi
|
||||
|
||||
echo "PASS: $name ($(wc -c < "$dir/run/$outfile") bytes)"
|
||||
}
|
||||
|
||||
# run_case_type_error <case-name> <expected message fragment> <run_case args...>
|
||||
# Stages a case the same way run_case() does and asserts that generation FAILS
|
||||
# with a message naming the offender. The generator's die() paths are what keep
|
||||
# a dangling reference from reaching sing-box, where the failure would be a
|
||||
# rejected configuration and a reload that silently keeps the previous one.
|
||||
run_case_type_error() {
|
||||
local name="$1" expect="$2"; shift 2
|
||||
|
||||
# run_case() reports a non-zero generator as its own failure and sets the
|
||||
# global FAILED - but here that non-zero exit *is* the expected outcome, so
|
||||
# the flag is saved and restored around the call. Its return status is
|
||||
# always 0 (the function ends with `echo "PASS: ..."`), so the refusal is
|
||||
# observed through the artifacts instead: the generator's stderr, and the
|
||||
# absence of the configuration it would otherwise write.
|
||||
local saved_failed="$FAILED"
|
||||
run_case "$name" "$@" > "$WORK/$name.out" 2>&1
|
||||
FAILED="$saved_failed"
|
||||
local err="$WORK/$name.err"
|
||||
|
||||
if [ -f "$WORK/$name/run/sing-box-c.json" ]; then
|
||||
echo "FAIL: $name: a configuration was generated, but it must be refused"
|
||||
FAILED=1
|
||||
elif [ ! -s "$err" ]; then
|
||||
echo "FAIL: $name: generation produced no diagnostic:"
|
||||
sed -n '1,5p' "$WORK/$name.out"
|
||||
FAILED=1
|
||||
elif grep -q "$expect" "$err"; then
|
||||
echo "PASS: $name: refused with a message naming the offender"
|
||||
else
|
||||
echo "FAIL: $name: the diagnostic does not name the offender (expected '$expect'):"
|
||||
head -3 "$err"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
# Without this the caller's status is the grep's, and a passing case reports
|
||||
# failure to the suite.
|
||||
return 0
|
||||
}
|
||||
|
||||
# The fixture's infra.self_mark, i.e. the value every node outbound has to
|
||||
# carry as routing_mark in the redirect modes.
|
||||
fixture_self_mark() {
|
||||
sed -n "s/^[[:space:]]*option self_mark '\([0-9]*\)'.*/\1/p" "$1" | head -1
|
||||
}
|
||||
|
||||
# Read one field out of a generated configuration. ucode is already required
|
||||
# by this suite, and a JSON walk is stronger than a grep: it cannot be
|
||||
# satisfied by a coincidental match elsewhere in the file.
|
||||
mkdir -p "$WORK"
|
||||
cat > "$WORK/probe.uc" <<'EOF'
|
||||
'use strict';
|
||||
|
||||
import { readfile } from 'fs';
|
||||
|
||||
const config = json(readfile(ARGV[0]));
|
||||
|
||||
switch (ARGV[1]) {
|
||||
case 'main-dns-server':
|
||||
for (let s in (config.dns?.servers || []))
|
||||
if (s.tag === 'main-dns') {
|
||||
printf('%s\n', s.server ?? '');
|
||||
exit(0);
|
||||
}
|
||||
printf('\n');
|
||||
break;
|
||||
case 'route-final':
|
||||
printf('%s\n', config.route?.final ?? '');
|
||||
break;
|
||||
case 'has-outbound-tag':
|
||||
for (let o in (config.outbounds || []))
|
||||
if (o.tag === ARGV[2]) {
|
||||
printf('yes\n');
|
||||
exit(0);
|
||||
}
|
||||
printf('no\n');
|
||||
break;
|
||||
case 'rule-set-count':
|
||||
printf('%d\n', length(config.route?.rule_set || []));
|
||||
break;
|
||||
default:
|
||||
printf('unknown probe\n');
|
||||
break;
|
||||
}
|
||||
EOF
|
||||
|
||||
config_field() {
|
||||
ucode "$WORK/probe.uc" "$1" "$2" "${3:-}" 2>"/dev/null"
|
||||
}
|
||||
|
||||
# Read one field out of a generated configuration. ucode is already required
|
||||
# by this suite, and a JSON walk is stronger than a grep: it cannot be
|
||||
# satisfied by a coincidental match elsewhere in the file.
|
||||
run_case client "$ROOT/tests/fixtures/generators/client.uci" generate_client.uc sing-box-c.json
|
||||
|
||||
# The preset remote rule-sets must be fetched through the node. A direct
|
||||
# download depends on the CDN staying reachable from mainland China and fails
|
||||
# intermittently under DNS pollution, which shows up as "open connection to
|
||||
# <ip>:443 using outbound/direct[direct]: i/o timeout" in sing-box-c.log.
|
||||
if grep -q '"detour": "direct-out"' "$WORK/client/run/sing-box-c.json"; then
|
||||
echo "FAIL: client: a remote rule-set would still be downloaded directly"
|
||||
FAILED=1
|
||||
fi
|
||||
if ! grep -q '"detour": "main-out"' "$WORK/client/run/sing-box-c.json"; then
|
||||
echo "FAIL: client: no remote rule-set is configured to download through main-out"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
# --- the product default proxy mode --------------------------------------
|
||||
#
|
||||
# client.uci runs `proxy_mode 'tun'`, where self_mark is empty and no outbound
|
||||
# carries routing_mark. That made it the only end-to-end fixture for as long
|
||||
# as the default mode (redirect_tproxy) was never generated here - and the
|
||||
# adapter's COMMON_FIELDS neutralized routing_mark (it listed the key as null
|
||||
# *after* the literal that sets it), so every node outbound lost the mark that
|
||||
# keeps sing-box's own proxy connection out of the nft redirect chain. This
|
||||
# case covers the default mode and asserts the mark on the emitted bytes.
|
||||
run_case redirect "$ROOT/tests/fixtures/generators/redirect.uci" generate_client.uc sing-box-c.json
|
||||
|
||||
redir_fixture="$ROOT/tests/fixtures/generators/redirect.uci"
|
||||
redir_json="$WORK/redirect/run/sing-box-c.json"
|
||||
redir_mark="$(fixture_self_mark "$redir_fixture")"
|
||||
|
||||
if [ -z "$redir_mark" ]; then
|
||||
echo "FAIL: redirect: $redir_fixture no longer declares infra.self_mark, so the"
|
||||
echo " routing_mark assertion would pass vacuously"
|
||||
FAILED=1
|
||||
elif [ ! -f "$redir_json" ]; then
|
||||
echo "FAIL: redirect: no config was generated"
|
||||
FAILED=1
|
||||
else
|
||||
if ! grep -q '"tag": "redirect-in"' "$redir_json"; then
|
||||
echo "FAIL: redirect: proxy_mode=redirect_tproxy did not emit redirect-in"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
# tproxy-in is gated on a dedicated UDP node, exactly like the nft
|
||||
# tproxy chain in firewall_post.ut. This fixture leaves main_udp_node at
|
||||
# 'nil', so the inbound must NOT be emitted: the old code emitted it
|
||||
# anyway with listen_port 0 - context.uc only assigns tproxy_port when a
|
||||
# UDP node exists - binding a random UDP port that no rule ever points
|
||||
# at. The positive case is redirect-udp below.
|
||||
if grep -q '"tag": "tproxy-in"' "$redir_json"; then
|
||||
echo "FAIL: redirect: main_udp_node=nil emitted tproxy-in, which no nft rule"
|
||||
echo " points at (see the tproxy_port gate in generator/inbound.uc)"
|
||||
FAILED=1
|
||||
fi
|
||||
if grep -q '"listen_port": 0' "$redir_json"; then
|
||||
echo "FAIL: redirect: a generated inbound has listen_port 0:"
|
||||
grep -n -B 3 '"listen_port": 0' "$redir_json" | head -8
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
# Which outbounds must carry the mark: every outbound that dials. Group
|
||||
# outbounds (urltest/selector) must NOT - sing-box rejects the field on
|
||||
# them with `json: unknown field "routing_mark"` - and block-out never
|
||||
# dials. A structural walk is used instead of a grep count so the
|
||||
# group/leaf distinction cannot silently rot into a vacuous assertion.
|
||||
cat > "$WORK/markcheck.uc" <<'EOF'
|
||||
'use strict';
|
||||
|
||||
import { readfile } from 'fs';
|
||||
|
||||
const want = +ARGV[0];
|
||||
const config = json(readfile(ARGV[1]));
|
||||
let checked = 0, problems = 0;
|
||||
|
||||
for (let ob in (config.outbounds || [])) {
|
||||
if (index(['block', 'urltest', 'selector'], ob.type) !== -1)
|
||||
continue;
|
||||
|
||||
checked++;
|
||||
|
||||
if (ob.routing_mark !== want)
|
||||
problems++;
|
||||
}
|
||||
|
||||
printf('%d %d\n', checked, problems);
|
||||
EOF
|
||||
|
||||
mark_result="$(ucode "$WORK/markcheck.uc" "$redir_mark" "$redir_json" 2>"/dev/null")"
|
||||
mark_checked="${mark_result%% *}"
|
||||
mark_problems="${mark_result##* }"
|
||||
|
||||
case "$mark_checked" in
|
||||
''|*[!0-9]*)
|
||||
echo "FAIL: redirect: could not check the emitted outbounds ($redir_json)"
|
||||
FAILED=1
|
||||
;;
|
||||
0)
|
||||
echo "FAIL: redirect: no dialling outbound was emitted, the assertion is vacuous"
|
||||
FAILED=1
|
||||
;;
|
||||
*)
|
||||
if [ "$mark_problems" -ne 0 ]; then
|
||||
echo "FAIL: redirect: $mark_problems of $mark_checked dialling outbounds do not"
|
||||
echo " carry routing_mark=$redir_mark (see COMMON_FIELDS in config/adapter.uc);"
|
||||
echo " without it sing-box does not mark its own sockets and the nft OUTPUT"
|
||||
echo " redirect chain loops the proxy connection back into its own inbound"
|
||||
FAILED=1
|
||||
else
|
||||
echo "PASS: redirect: all $mark_checked dialling outbounds carry routing_mark=$redir_mark"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# The UDP tproxy path: a dedicated UDP node makes context.uc assign
|
||||
# tproxy_port, firewall_post.ut emit the tproxy chain, and inbound.uc emit the
|
||||
# tproxy-in that chain redirects to. No fixture covered this path before -
|
||||
# every one either used tun or left main_udp_node at 'nil'.
|
||||
run_case redirect-udp "$ROOT/tests/fixtures/generators/redirect.uci" generate_client.uc sing-box-c.json \
|
||||
"s/^\([[:space:]]*\)option main_udp_node '.*'/\1option main_udp_node 'same'/"
|
||||
|
||||
rudp_json="$WORK/redirect-udp/run/sing-box-c.json"
|
||||
if [ ! -f "$rudp_json" ]; then
|
||||
echo "FAIL: redirect-udp: no config was generated"
|
||||
FAILED=1
|
||||
elif ! grep -q '"tag": "tproxy-in"' "$rudp_json"; then
|
||||
echo "FAIL: redirect-udp: main_udp_node=same did not emit tproxy-in"
|
||||
FAILED=1
|
||||
elif ! grep -A 4 '"tag": "tproxy-in"' "$rudp_json" | grep -q '"listen_port": 5332'; then
|
||||
echo "FAIL: redirect-udp: tproxy-in does not listen on the configured tproxy port:"
|
||||
grep -A 4 '"tag": "tproxy-in"' "$rudp_json" | head -6
|
||||
FAILED=1
|
||||
else
|
||||
echo "PASS: redirect-udp: a dedicated UDP node emits tproxy-in on 5332"
|
||||
fi
|
||||
|
||||
# H1: custom-only scalars must not survive into a preset mode.
|
||||
#
|
||||
# route.uc and outbound.uc choose their branch with `isEmpty(default_outbound)`
|
||||
# while the preset DNS builder keys off main_node. A residual
|
||||
# routing.default_outbound (LuCI hides the field in preset modes but does not
|
||||
# clear it) therefore switched generation onto the custom branch, the DNS block
|
||||
# silently lost main-dns/china-dns, and the rule-set tags the leftover custom
|
||||
# rules referenced were never built - `sing-box check` rejected the whole
|
||||
# configuration, so the reload kept the old one and the user only saw "my
|
||||
# change did not take". client.uci is the preset path with no main node; the
|
||||
# expression below is a no-op unless generation writes the field.
|
||||
run_case preset-stale-default-outbound "$ROOT/tests/fixtures/generators/client.uci" generate_client.uc sing-box-c.json \
|
||||
"s#^\\([[:space:]]*\\)option proxy_mode .*#\\1option proxy_mode 'redirect_tproxy'\\n\\1option main_udp_node 'same'\\n\\1option default_outbound 'direct-out'#"
|
||||
|
||||
# A residual routing.default_outbound must not switch a preset-mode router onto
|
||||
# the custom branch: the preset DNS builder keys off main_node and bails out,
|
||||
# so the DNS block would lose main-dns/china-dns, and the leftover custom rules
|
||||
# reference rule-set tags that only custom mode builds. The expression above
|
||||
# inserts the stale option; with the mode gate in build_context() it is never
|
||||
# read, so the generated config must still be the preset one.
|
||||
pso_json="$WORK/preset-stale-default-outbound/run/sing-box-c.json"
|
||||
if [ ! -f "$pso_json" ]; then
|
||||
echo "FAIL: preset-stale-default-outbound: no config was generated"
|
||||
FAILED=1
|
||||
else
|
||||
for probe in '"tag": "main-dns"' '"tag": "china-dns"'; do
|
||||
if ! grep -q "$probe" "$pso_json"; then
|
||||
echo "FAIL: preset-stale-default-outbound: the preset DNS path is missing $probe"
|
||||
echo " a residual routing.default_outbound put generation on the custom branch"
|
||||
FAILED=1
|
||||
fi
|
||||
done
|
||||
if ! grep -q '"final": "main-out"' "$pso_json"; then
|
||||
echo "FAIL: preset-stale-default-outbound: route.final is not main-out"
|
||||
FAILED=1
|
||||
fi
|
||||
# find_neighbor is emitted only by the custom route builder.
|
||||
if grep -q '"find_neighbor"' "$pso_json"; then
|
||||
echo "FAIL: preset-stale-default-outbound: find_neighbor leaked into the preset config"
|
||||
FAILED=1
|
||||
fi
|
||||
if ! grep -q '"type": "redirect"' "$pso_json"; then
|
||||
echo "FAIL: preset-stale-default-outbound: the redirect inbound is missing"
|
||||
FAILED=1
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
run_case wan-dns "$ROOT/tests/fixtures/generators/client.uci" generate_client.uc sing-box-c.json \
|
||||
"s/^\([[:space:]]*\)option dns_server '.*'/\1option dns_server 'wan'/"
|
||||
|
||||
wan_json="$WORK/wan-dns/run/sing-box-c.json"
|
||||
if [ ! -f "$wan_json" ]; then
|
||||
echo "FAIL: wan-dns: no config was generated"
|
||||
FAILED=1
|
||||
else
|
||||
wan_server="$(config_field "$wan_json" main-dns-server)"
|
||||
case "$wan_server" in
|
||||
'')
|
||||
echo "FAIL: wan-dns: main-dns has no server at all (dns_server='wan' was dropped)"
|
||||
FAILED=1
|
||||
;;
|
||||
wan)
|
||||
echo "FAIL: wan-dns: the literal 'wan' was published as the main DNS hostname;"
|
||||
echo " sing-box would try to resolve a host by that name"
|
||||
FAILED=1
|
||||
;;
|
||||
*)
|
||||
echo "PASS: wan-dns: dns_server='wan' resolved to $wan_server"
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# --- A1: the generator is a pure function of its arguments ----------------
|
||||
#
|
||||
# generator/client.uc used to resolve its own runtime environment - ubus for
|
||||
# the WAN resolver, readfile() for the two domain-resource lists - while being
|
||||
# documented as a pure function. "Generate the same config twice" was therefore
|
||||
# not guaranteed, and reload's preflight generation was not provably the
|
||||
# artifact start_service regenerated. The impure step now lives in the CLI
|
||||
# shell, which hands the values to generate(dm, env).
|
||||
#
|
||||
# Both halves of that contract are pinned here, through the production entry
|
||||
# point (not a test-only driver): the same inputs must produce byte-identical
|
||||
# output, and a changed GenerationContext input must reach the generator. The
|
||||
# comparison is exact string equality rather than a hash, because busybox has
|
||||
# no cksum and macOS has no md5sum by default.
|
||||
det_dir="$WORK/client"
|
||||
det_gen="generate_client.uc"
|
||||
det_out="$det_dir/run/sing-box-c.json"
|
||||
det_first="$det_dir/determinism-first.json"
|
||||
|
||||
cp "$det_out" "$det_first"
|
||||
( cd "$det_dir/scripts" && ucode -L "$det_dir/scripts" "$det_gen" ) >"/dev/null" 2>&1
|
||||
if [ "$(cat "$det_out")" = "$(cat "$det_first")" ]; then
|
||||
echo "PASS: the same generation inputs produce byte-identical output"
|
||||
else
|
||||
echo "FAIL: two identical generation runs produced different output"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
# direct_list.txt is read by the CLI and passed in as env.direct_domain_list.
|
||||
# If that plumbing were lost - the exact shape of the old hidden read - the file
|
||||
# would be ignored and the output would not move.
|
||||
printf 'determinism.example.com\n' > "$det_dir/resources/direct_list.txt"
|
||||
( cd "$det_dir/scripts" && ucode -L "$det_dir/scripts" "$det_gen" ) >"/dev/null" 2>&1
|
||||
if [ "$(cat "$det_out")" != "$(cat "$det_first")" ]; then
|
||||
echo "PASS: a changed GenerationContext input changes the generated output"
|
||||
else
|
||||
echo "FAIL: the domain-resource list did not reach the generator"
|
||||
FAILED=1
|
||||
fi
|
||||
if ! grep -qF 'determinism.example.com' "$det_out"; then
|
||||
echo "FAIL: the domain from direct_list.txt is absent from the generated config"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
run_case custom "$ROOT/tests/fixtures/generators/custom.uci" generate_client.uc sing-box-c.json
|
||||
|
||||
# P1-6: the main-node reference is mode-dependent, and LuCI hides config.main_node
|
||||
# in custom mode without clearing it (`rmempty = false`). A router that was
|
||||
# configured in a preset mode and then switched to custom therefore keeps the
|
||||
# old value - and reading it in custom mode switched the generator back to the
|
||||
# preset path, dropping every routing_rule/rule_set the user configured.
|
||||
run_case custom-stale-main-node "$ROOT/tests/fixtures/generators/custom.uci" generate_client.uc sing-box-c.json \
|
||||
"s/^\([[:space:]]*\)option log_level 'error'/\1option log_level 'error'\n\1option main_node 'n_direct'/"
|
||||
|
||||
stale_json="$WORK/custom-stale-main-node/run/sing-box-c.json"
|
||||
if [ ! -f "$stale_json" ]; then
|
||||
echo "FAIL: custom-stale-main-node: no config was generated"
|
||||
FAILED=1
|
||||
else
|
||||
stale_final="$(config_field "$stale_json" route-final)"
|
||||
stale_rulesets="$(config_field "$stale_json" rule-set-count)"
|
||||
stale_main_out="$(config_field "$stale_json" has-outbound-tag main-out)"
|
||||
if [ "$stale_main_out" = "yes" ]; then
|
||||
echo "FAIL: custom: a residual config.main_node switched the generator back to the"
|
||||
echo " preset path (the generated config has main-out), so every routing_rule"
|
||||
echo " and rule_set the user configured was dropped"
|
||||
FAILED=1
|
||||
elif [ "$stale_final" != "direct-out" ]; then
|
||||
echo "FAIL: custom: route.final is '$stale_final', expected the fixture's custom"
|
||||
echo " default_outbound (direct-out)"
|
||||
FAILED=1
|
||||
elif [ "$stale_rulesets" = "0" ]; then
|
||||
echo "FAIL: custom: the fixture's local rule_set is missing from the generated config"
|
||||
FAILED=1
|
||||
else
|
||||
echo "PASS: custom: a residual main_node does not override custom routing"
|
||||
fi
|
||||
fi
|
||||
|
||||
run_case server "$ROOT/tests/fixtures/generators/server.uci" generate_server.uc sing-box-s.json
|
||||
|
||||
# WireGuard is emitted as a sing-box endpoint, not an outbound, and it has its
|
||||
# own builder. A3 converted the call sites to pass a Node but left
|
||||
# generate_endpoint() reading flat UCI keys, so the key material silently
|
||||
# disappeared and sing-box rejected the config. `sing-box check` alone is not
|
||||
# a strong enough guard (a config with no server at all can still be valid),
|
||||
# so assert the endpoint actually carries the fixture's keys.
|
||||
run_case wireguard "$ROOT/tests/fixtures/generators/wireguard.uci" generate_client.uc sing-box-c.json
|
||||
|
||||
wg_json="$WORK/wireguard/run/sing-box-c.json"
|
||||
if [ ! -f "$wg_json" ]; then
|
||||
echo "FAIL: wireguard: no config was generated"
|
||||
FAILED=1
|
||||
else
|
||||
if ! grep -qF '"type": "wireguard"' "$wg_json"; then
|
||||
echo "FAIL: wireguard: no wireguard endpoint in the generated config"
|
||||
FAILED=1
|
||||
fi
|
||||
if ! grep -qF 'iKaNuoWRQTFPD5V3OoMNdMshsMgU9t7rolJNpgNx+UM=' "$wg_json"; then
|
||||
echo "FAIL: wireguard: the endpoint lost its private key"
|
||||
FAILED=1
|
||||
fi
|
||||
if ! grep -qF 'DDcdTHUv0Q6XYDf9l93jzwwuoY/G1TC+g74QH0A9HmM=' "$wg_json"; then
|
||||
echo "FAIL: wireguard: the peer lost its public key"
|
||||
FAILED=1
|
||||
fi
|
||||
if ! grep -qF '"172.16.0.2/32"' "$wg_json"; then
|
||||
echo "FAIL: wireguard: the endpoint lost its local address list"
|
||||
FAILED=1
|
||||
fi
|
||||
fi
|
||||
|
||||
# A broken urltest candidate must be pruned, not fatal: the old behaviour was
|
||||
# a die() that left the router with no configuration at all.
|
||||
run_case partial_invalid "$ROOT/tests/fixtures/generators/partial_invalid.uci" generate_client.uc sing-box-c.json
|
||||
|
||||
pi_json="$WORK/partial_invalid/run/sing-box-c.json"
|
||||
if [ ! -f "$pi_json" ]; then
|
||||
echo "FAIL: partial_invalid: a single broken urltest node aborted the whole config"
|
||||
FAILED=1
|
||||
else
|
||||
if ! grep -qF '"cfg-n_ok-out"' "$pi_json"; then
|
||||
echo "FAIL: partial_invalid: the buildable candidate was dropped too"
|
||||
FAILED=1
|
||||
fi
|
||||
if grep -qF '"cfg-n_broken-out"' "$pi_json"; then
|
||||
echo "FAIL: partial_invalid: the broken candidate was emitted"
|
||||
FAILED=1
|
||||
fi
|
||||
if ! grep -q "skipping urltest candidate 'n_broken'" "$WORK/partial_invalid/generate.err"; then
|
||||
echo "FAIL: partial_invalid: the broken candidate was dropped without a warning"
|
||||
FAILED=1
|
||||
fi
|
||||
fi
|
||||
|
||||
# A direct node as the main node leaves main-out with no fields of its own, and
|
||||
# sing-box refuses to detour into an empty direct outbound - both the main-dns
|
||||
# server and the rule-set http_client used to, so the service never started.
|
||||
# `sing-box check` accepts the file (only `sing-box run` rejects it), so assert
|
||||
# on the generated JSON rather than trusting check.
|
||||
run_case direct_main "$ROOT/tests/fixtures/generators/direct_main.uci" generate_client.uc sing-box-c.json
|
||||
|
||||
dm_json="$WORK/direct_main/run/sing-box-c.json"
|
||||
if [ ! -f "$dm_json" ]; then
|
||||
echo "FAIL: direct_main: no config was generated"
|
||||
FAILED=1
|
||||
else
|
||||
if ! grep -q '"tag": "main-out"' "$dm_json"; then
|
||||
echo "FAIL: direct_main: main-out is missing from the generated config"
|
||||
FAILED=1
|
||||
fi
|
||||
if grep -q '"detour": "main-out"' "$dm_json"; then
|
||||
echo "FAIL: direct_main: something still detours into the empty direct main-out:"
|
||||
grep -n '"detour": "main-out"' "$dm_json" | head -3
|
||||
echo " sing-box run rejects it with 'detour to an empty direct outbound makes no sense'"
|
||||
FAILED=1
|
||||
fi
|
||||
fi
|
||||
|
||||
# H3: a reference to a dns_server or ruleset that is disabled (or deleted) used
|
||||
# to be emitted verbatim as `cfg-<name>-dns` / `cfg-<name>-rule`. Nothing
|
||||
# defines those tags, so sing-box rejected the whole configuration with
|
||||
# "dns server not found" / "rule-set not found" - and because the rejected
|
||||
# generation never reaches the running service, the reload kept the previous
|
||||
# configuration and the user only saw that their change did not take.
|
||||
run_case_type_error dangling-resolver "does not exist or is disabled" \
|
||||
"$ROOT/tests/fixtures/generators/custom.uci" generate_client.uc sing-box-c.json \
|
||||
"s#domain_resolver 'default-dns'#domain_resolver 'rs_local_gone'#"
|
||||
|
||||
exit ${FAILED:-0}
|
||||
@@ -0,0 +1,182 @@
|
||||
#!/bin/sh
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# Golden snapshot for the server inbound adapter (PR-04).
|
||||
#
|
||||
# Builds one inbound per protocol with tests/fixtures/generators/server.uci
|
||||
# and diffs the result against tests/snapshots/generator/inbounds.json.
|
||||
# Adding a protocol field, renaming a sing-box option or reordering the
|
||||
# inbound field tables shows up as a reviewable diff instead of passing
|
||||
# silently.
|
||||
#
|
||||
# This is the server-side counterpart of test_golden_outbounds.sh. Before
|
||||
# PR-04 the server path had no snapshot at all, which is how the fixture's
|
||||
# `listen_port` (an option neither the form nor the generator uses) stayed
|
||||
# in place: the generated config simply had no listen_port and
|
||||
# `sing-box check` accepted it.
|
||||
#
|
||||
# HP_UPDATE_SNAPSHOTS=1 sh tests/ucode/test_golden_inbounds.sh <repo-root>
|
||||
# regenerates the snapshot (review the diff before committing it).
|
||||
#
|
||||
# Without sing-box on PATH the diff still runs but the schema check is only
|
||||
# reported as NOT RUN (degraded). HP_REQUIRE_SINGBOX=1 turns that into a
|
||||
# failure; it mirrors HP_REQUIRE_FW4 in test_firewall_template.sh.
|
||||
#
|
||||
# Usage: sh tests/ucode/test_golden_inbounds.sh <repo-root> [work-dir]
|
||||
|
||||
ROOT="${1:-.}"
|
||||
WORK="${2:-/tmp/hp-golden-inbounds-test}"
|
||||
|
||||
ROOT="$(cd "$ROOT" && pwd)"
|
||||
SNAPSHOT="$ROOT/tests/snapshots/generator/inbounds.json"
|
||||
FIXTURE="$ROOT/tests/fixtures/generators/server.uci"
|
||||
FAILED=0
|
||||
|
||||
rm -rf "$WORK"
|
||||
mkdir -p "$WORK/scripts/config" "$WORK/scripts/parser"
|
||||
|
||||
# HP_VALIDATE_DATA lets a development host replace /sbin/validate_data; the
|
||||
# loader itself does not validate, but homeproxy.uc is imported by the adapter.
|
||||
VALIDATE_DATA="${HP_VALIDATE_DATA:-/sbin/validate_data}"
|
||||
sed -e "s#/sbin/validate_data#${VALIDATE_DATA}#" \
|
||||
"$ROOT/root/etc/homeproxy/scripts/homeproxy.uc" > "$WORK/scripts/homeproxy.uc"
|
||||
|
||||
cp "$ROOT/root/etc/homeproxy/scripts/config/loader.uc" "$WORK/scripts/config/"
|
||||
cp "$ROOT/root/etc/homeproxy/scripts/config/model.uc" "$WORK/scripts/config/"
|
||||
cp "$ROOT/root/etc/homeproxy/scripts/config/adapter.uc" "$WORK/scripts/config/"
|
||||
cp "$ROOT/root/etc/homeproxy/scripts/parser/"*.uc "$WORK/scripts/parser/"
|
||||
cp "$FIXTURE" "$WORK/scripts/config/homeproxy"
|
||||
|
||||
# One inbound per server section, keyed by the UCI section name so the
|
||||
# snapshot is stable across runs and only changes when the adapter or the
|
||||
# fixture changes.
|
||||
#
|
||||
# The ACME provider carries an absolute data_directory (HP_DIR + '/certs'),
|
||||
# which differs between the work dir and a target device. Normalise it to a
|
||||
# placeholder so the snapshot is portable; this is the same "path-only
|
||||
# difference" allowance the outbound snapshot relies on.
|
||||
# ucode resolves module-level names lexically, so the helper has to be
|
||||
# declared above its caller.
|
||||
cat > "$WORK/scripts/golden.uc" <<'EOF'
|
||||
'use strict';
|
||||
|
||||
import { InboundFactory } from './config/adapter.uc';
|
||||
import { Loader } from './config/loader.uc';
|
||||
|
||||
/* Replace the absolute certs directory with a placeholder. */
|
||||
function rewrite_paths(tls) {
|
||||
if (tls && tls.certificate_provider)
|
||||
tls.certificate_provider.data_directory = '<HP_DIR>/certs';
|
||||
return tls;
|
||||
}
|
||||
|
||||
const dm = Loader.load('./config');
|
||||
const inbounds = {};
|
||||
|
||||
for (let inbound in (dm.server.inbounds || [])) {
|
||||
const built = InboundFactory.tryCreate(inbound);
|
||||
|
||||
if (length(built.problems))
|
||||
die(sprintf('inbound %s: %s\n', inbound.id, join('; ', built.problems)));
|
||||
|
||||
built.inbound.tls = rewrite_paths(built.inbound.tls);
|
||||
inbounds[inbound.id] = built.inbound;
|
||||
}
|
||||
|
||||
printf('%.J\n', inbounds);
|
||||
EOF
|
||||
|
||||
if ! ( cd "$WORK/scripts" && ucode -L "$WORK/scripts" golden.uc > "$WORK/inbounds.json" 2> "$WORK/golden.err" ); then
|
||||
echo "FAIL: could not build the protocol inbounds"
|
||||
head -8 "$WORK/golden.err"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -s "$WORK/inbounds.json" ]; then
|
||||
echo "FAIL: the adapter produced no inbounds (fixture not loaded?)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# An empty result is the failure mode of a UCI syntax error in the fixture:
|
||||
# uci.load() yields nothing and the adapter happily builds zero inbounds.
|
||||
protocols="$(grep -c '"tag"' "$WORK/inbounds.json")"
|
||||
if [ "$protocols" -lt 6 ]; then
|
||||
echo "FAIL: only $protocols protocol inbounds were built; the fixture did not load correctly"
|
||||
head -5 "$WORK/inbounds.json"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "${HP_UPDATE_SNAPSHOTS:-0}" = "1" ]; then
|
||||
mkdir -p "$(dirname "$SNAPSHOT")"
|
||||
cp "$WORK/inbounds.json" "$SNAPSHOT"
|
||||
echo "UPDATED: ${SNAPSHOT#"$ROOT"/}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ ! -s "$SNAPSHOT" ]; then
|
||||
echo "FAIL: $SNAPSHOT is missing; regenerate it with HP_UPDATE_SNAPSHOTS=1"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# `cmp` rather than `diff` for the verdict: busybox on the target has no
|
||||
# `diff`, and this suite runs there as well as on a development host.
|
||||
if cmp -s "$SNAPSHOT" "$WORK/inbounds.json"; then
|
||||
echo "PASS: $protocols protocol inbounds match the golden snapshot"
|
||||
else
|
||||
echo "FAIL: the generated inbounds differ from the golden snapshot"
|
||||
if command -v diff > "/dev/null" 2>&1; then
|
||||
diff -u "$SNAPSHOT" "$WORK/inbounds.json" | head -40
|
||||
else
|
||||
echo " (no 'diff' here; compare ${SNAPSHOT#"$ROOT"/} with $WORK/inbounds.json)"
|
||||
fi
|
||||
echo " (review the change; if it is intended, re-run with HP_UPDATE_SNAPSHOTS=1)"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
# The snapshot diff catches a *change*; this catches a *wrong* value that was
|
||||
# snapshotted. Every emitted inbound is handed to the real schema checker.
|
||||
#
|
||||
# The data_directory placeholder is only valid inside the snapshot, so the
|
||||
# schema check runs against the un-rewritten config.
|
||||
if command -v sing-box > "/dev/null" 2>&1; then
|
||||
cat > "$WORK/scripts/schema.uc" <<'EOF'
|
||||
'use strict';
|
||||
|
||||
import { writefile } from 'fs';
|
||||
|
||||
import { InboundFactory } from './config/adapter.uc';
|
||||
import { Loader } from './config/loader.uc';
|
||||
|
||||
const dm = Loader.load('./config');
|
||||
const list = [];
|
||||
|
||||
for (let inbound in (dm.server.inbounds || []))
|
||||
push(list, InboundFactory.create(inbound));
|
||||
|
||||
writefile('schema.json', sprintf('%.J\n', { inbounds: list }));
|
||||
EOF
|
||||
if ( cd "$WORK/scripts" && ucode -L "$WORK/scripts" schema.uc ) \
|
||||
&& sing-box check --config "$WORK/scripts/schema.json"; then
|
||||
echo "PASS: sing-box check accepted every golden inbound"
|
||||
else
|
||||
echo "FAIL: sing-box rejected one of the golden inbounds"
|
||||
FAILED=1
|
||||
fi
|
||||
else
|
||||
echo "NOT RUN: sing-box is not on PATH, schema check skipped"
|
||||
# A skipped schema check must not read as "verified". The snapshot diff
|
||||
# above catches a *change* and says nothing about whether the emitted JSON
|
||||
# is one sing-box would accept, so say plainly that this run is degraded.
|
||||
# HP_REQUIRE_SINGBOX=1 (the same shape as HP_REQUIRE_FW4 in
|
||||
# test_firewall_template.sh) turns the skip into a failure for callers that
|
||||
# know sing-box must be there - a target always has it.
|
||||
echo " DEGRADED: the golden inbounds were only diffed against the"
|
||||
echo " snapshot, not verified against the real sing-box schema"
|
||||
if [ "${HP_REQUIRE_SINGBOX:-0}" = "1" ]; then
|
||||
echo "FAIL: HP_REQUIRE_SINGBOX=1 but sing-box is not on PATH, so the"
|
||||
echo " golden inbounds are unverified"
|
||||
FAILED=1
|
||||
fi
|
||||
fi
|
||||
|
||||
exit $FAILED
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
#!/bin/sh
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# Golden snapshot for the protocol adapter.
|
||||
#
|
||||
# Builds one outbound per protocol with tests/fixtures/generators/outbounds.uci
|
||||
# and diffs the result against tests/snapshots/generator/outbounds.json.
|
||||
# Adding a protocol field, renaming a sing-box option or reordering the field
|
||||
# tables shows up as a reviewable diff instead of passing silently.
|
||||
#
|
||||
# This replaces tests/ucode/test_demo_architecture.sh, which imported the
|
||||
# production OutboundFactory and compared it against generate_outbound() - the
|
||||
# same function - so it could never fail.
|
||||
#
|
||||
# HP_UPDATE_SNAPSHOTS=1 sh tests/ucode/test_golden_outbounds.sh <repo-root>
|
||||
# regenerates the snapshot (review the diff before committing it).
|
||||
#
|
||||
# Without sing-box on PATH the diff still runs but the schema check is only
|
||||
# reported as NOT RUN (degraded). HP_REQUIRE_SINGBOX=1 turns that into a
|
||||
# failure; it mirrors HP_REQUIRE_FW4 in test_firewall_template.sh.
|
||||
#
|
||||
# Usage: sh tests/ucode/test_golden_outbounds.sh <repo-root> [work-dir]
|
||||
|
||||
ROOT="${1:-.}"
|
||||
WORK="${2:-/tmp/hp-golden-test}"
|
||||
|
||||
ROOT="$(cd "$ROOT" && pwd)"
|
||||
SNAPSHOT="$ROOT/tests/snapshots/generator/outbounds.json"
|
||||
FIXTURE="$ROOT/tests/fixtures/generators/outbounds.uci"
|
||||
FAILED=0
|
||||
|
||||
rm -rf "$WORK"
|
||||
mkdir -p "$WORK/scripts/config"
|
||||
|
||||
# HP_VALIDATE_DATA lets a development host replace /sbin/validate_data; the
|
||||
# loader itself does not validate, but homeproxy.uc is imported by the adapter.
|
||||
VALIDATE_DATA="${HP_VALIDATE_DATA:-/sbin/validate_data}"
|
||||
sed -e "s#/sbin/validate_data#${VALIDATE_DATA}#" \
|
||||
"$ROOT/root/etc/homeproxy/scripts/homeproxy.uc" > "$WORK/scripts/homeproxy.uc"
|
||||
|
||||
cp "$ROOT/root/etc/homeproxy/scripts/config/loader.uc" "$WORK/scripts/config/"
|
||||
cp "$ROOT/root/etc/homeproxy/scripts/config/model.uc" "$WORK/scripts/config/"
|
||||
cp "$ROOT/root/etc/homeproxy/scripts/config/adapter.uc" "$WORK/scripts/config/"
|
||||
# PR-02: config/loader.uc imports '../parser/mapping.uc'; stage the
|
||||
# parser tree as a sibling of config/ or nothing loads.
|
||||
mkdir -p "$WORK/scripts/parser"
|
||||
cp "$ROOT/root/etc/homeproxy/scripts/parser/"*.uc "$WORK/scripts/parser/"
|
||||
cp "$FIXTURE" "$WORK/scripts/config/homeproxy"
|
||||
|
||||
# One outbound per node, in fixture order. %.J keeps the JSON value-exact and
|
||||
# the key order is the field-table order, so the snapshot is stable across
|
||||
# runs and only changes when the adapter changes.
|
||||
cat > "$WORK/scripts/golden.uc" <<'EOF'
|
||||
'use strict';
|
||||
|
||||
import { Loader } from './config/loader.uc';
|
||||
import { OutboundFactory } from './config/adapter.uc';
|
||||
|
||||
const dm = Loader.load('./config');
|
||||
const outbounds = {};
|
||||
|
||||
for (let node in dm.nodes)
|
||||
outbounds[node.id] = OutboundFactory.create(node, dm.infra.self_mark);
|
||||
|
||||
printf('%.J\n', outbounds);
|
||||
EOF
|
||||
|
||||
if ! ( cd "$WORK/scripts" && ucode -L "$WORK/scripts" golden.uc > "$WORK/outbounds.json" 2> "$WORK/golden.err" ); then
|
||||
echo "FAIL: could not build the protocol outbounds"
|
||||
head -8 "$WORK/golden.err"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -s "$WORK/outbounds.json" ]; then
|
||||
echo "FAIL: the adapter produced no outbounds (fixture not loaded?)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# An empty result is the failure mode of a UCI syntax error in the fixture
|
||||
# (e.g. a C-style comment, which UCI does not accept): uci.load() yields
|
||||
# nothing and the adapter happily builds zero outbounds. Fail loudly instead.
|
||||
protocols="$(grep -c '"tag"' "$WORK/outbounds.json")"
|
||||
if [ "$protocols" -lt 10 ]; then
|
||||
echo "FAIL: only $protocols protocol outbounds were built; the fixture did not load correctly"
|
||||
head -5 "$WORK/outbounds.json"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The runtime-owned fields the literal in build_outbound() sets are applied
|
||||
# BEFORE the COMMON_FIELDS table, so listing one of them there (even as null)
|
||||
# silently deletes it. routing_mark is exactly that case: without it sing-box
|
||||
# does not mark its own sockets and the nft redirect chain loops the proxy
|
||||
# connection back into sing-box's own redirect inbound.
|
||||
#
|
||||
# Deliberately checked against the emitted JSON rather than through
|
||||
# `sing-box check`: routing_mark is a Linux-only field, so the schema check
|
||||
# below accepts it on Linux and rejects it elsewhere, which is how this
|
||||
# regression survived a green suite. Asserting on the bytes is
|
||||
# platform-independent.
|
||||
SELF_MARK="$(sed -n "s/^[[:space:]]*option self_mark '\([0-9]*\)'.*/\1/p" "$FIXTURE" | head -1)"
|
||||
if [ -z "$SELF_MARK" ]; then
|
||||
echo "FAIL: $FIXTURE declares no infra.self_mark, so the mark cannot be asserted"
|
||||
exit 1
|
||||
fi
|
||||
marked="$(grep -c "\"routing_mark\": $SELF_MARK" "$WORK/outbounds.json")"
|
||||
if [ "$marked" -ne "$protocols" ]; then
|
||||
echo "FAIL: $protocols node outbounds were built, but only $marked carry routing_mark=$SELF_MARK"
|
||||
echo " every node outbound must carry the runtime mark (see COMMON_FIELDS in"
|
||||
echo " config/adapter.uc): without it the nft OUTPUT redirect chain sends"
|
||||
echo " sing-box's own proxy connection back into its redirect inbound"
|
||||
exit 1
|
||||
fi
|
||||
echo "PASS: every node outbound carries routing_mark=$SELF_MARK"
|
||||
|
||||
if [ "${HP_UPDATE_SNAPSHOTS:-0}" = "1" ]; then
|
||||
mkdir -p "$(dirname "$SNAPSHOT")"
|
||||
cp "$WORK/outbounds.json" "$SNAPSHOT"
|
||||
echo "UPDATED: ${SNAPSHOT#"$ROOT"/}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ ! -s "$SNAPSHOT" ]; then
|
||||
echo "FAIL: $SNAPSHOT is missing; regenerate it with HP_UPDATE_SNAPSHOTS=1"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# `cmp` rather than `diff` for the verdict: busybox on the target has no
|
||||
# `diff`, and this suite runs there as well as on a development host.
|
||||
if cmp -s "$SNAPSHOT" "$WORK/outbounds.json"; then
|
||||
echo "PASS: $protocols protocol outbounds match the golden snapshot"
|
||||
else
|
||||
echo "FAIL: the generated outbounds differ from the golden snapshot"
|
||||
if command -v diff > "/dev/null" 2>&1; then
|
||||
diff -u "$SNAPSHOT" "$WORK/outbounds.json" | head -40
|
||||
else
|
||||
echo " (no 'diff' here; compare ${SNAPSHOT#"$ROOT"/} with $WORK/outbounds.json)"
|
||||
fi
|
||||
echo " (review the change; if it is intended, re-run with HP_UPDATE_SNAPSHOTS=1)"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
# The snapshot diff catches a *change*; this catches a *wrong* value that was
|
||||
# snapshotted. Every emitted outbound is handed to the real schema checker.
|
||||
if command -v sing-box > "/dev/null" 2>&1; then
|
||||
cp "$WORK/outbounds.json" "$WORK/scripts/outbounds.json"
|
||||
cat > "$WORK/scripts/wrap.uc" <<'EOF'
|
||||
'use strict';
|
||||
|
||||
import { readfile, writefile } from 'fs';
|
||||
|
||||
const outbounds = json(readfile('outbounds.json'));
|
||||
const list = [];
|
||||
|
||||
for (let id in keys(outbounds))
|
||||
push(list, outbounds[id]);
|
||||
|
||||
writefile('schema.json', sprintf('%.J\n', { outbounds: list }));
|
||||
EOF
|
||||
if ( cd "$WORK/scripts" && ucode -L "$WORK/scripts" wrap.uc ) \
|
||||
&& sing-box check --config "$WORK/scripts/schema.json"; then
|
||||
echo "PASS: sing-box check accepted every golden outbound"
|
||||
else
|
||||
echo "FAIL: sing-box rejected one of the golden outbounds"
|
||||
FAILED=1
|
||||
fi
|
||||
else
|
||||
echo "NOT RUN: sing-box is not on PATH, schema check skipped"
|
||||
# A skipped schema check must not read as "verified". The snapshot diff
|
||||
# above catches a *change* and says nothing about whether the emitted JSON
|
||||
# is one sing-box would accept, so say plainly that this run is degraded.
|
||||
# HP_REQUIRE_SINGBOX=1 (the same shape as HP_REQUIRE_FW4 in
|
||||
# test_firewall_template.sh) turns the skip into a failure for callers that
|
||||
# know sing-box must be there - a target always has it.
|
||||
echo " DEGRADED: the golden outbounds were only diffed against the"
|
||||
echo " snapshot, not verified against the real sing-box schema"
|
||||
if [ "${HP_REQUIRE_SINGBOX:-0}" = "1" ]; then
|
||||
echo "FAIL: HP_REQUIRE_SINGBOX=1 but sing-box is not on PATH, so the"
|
||||
echo " golden outbounds are unverified"
|
||||
FAILED=1
|
||||
fi
|
||||
fi
|
||||
|
||||
exit $FAILED
|
||||
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/ucode
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Copyright (C) 2025 ImmortalWrt.org
|
||||
*
|
||||
* Regression tests for homeproxy.uc helpers, focused on executeCommand():
|
||||
* return shape, stderr/exit-code capture, binary detection and (most
|
||||
* importantly) that the temporary descriptors are closed on every run.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { lsdir } from 'fs';
|
||||
import { executeCommand, isValidPEM, redactReason, redactUrl, shellQuote, wGETVerbose } from 'homeproxy';
|
||||
|
||||
let failures = 0,
|
||||
checks = 0;
|
||||
|
||||
function expect(name, actual, want) {
|
||||
checks++;
|
||||
if (sprintf('%J', actual) !== sprintf('%J', want)) {
|
||||
printf('FAIL %s: expected %J, got %J\n', name, want, actual);
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
|
||||
function fd_count() {
|
||||
let n = 0;
|
||||
for (let _entry in lsdir('/proc/self/fd'))
|
||||
n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
/* successful command */
|
||||
const ok = executeCommand('echo', 'hello');
|
||||
expect('ok.exitcode', ok.exitcode, 0);
|
||||
expect('ok.stdout', ok.stdout, 'hello\n');
|
||||
expect('ok.stderr', ok.stderr, '');
|
||||
expect('ok.binary', ok.binary, false);
|
||||
expect('ok.command', ok.command, 'echo hello');
|
||||
|
||||
/* failing command keeps stderr and the exit code */
|
||||
const bad = executeCommand('sh', '-c', shellQuote('echo oops >&2; exit 3'));
|
||||
expect('bad.exitcode', bad.exitcode, 3);
|
||||
expect('bad.stderr', bad.stderr, 'oops\n');
|
||||
expect('bad.stdout', bad.stdout, '');
|
||||
|
||||
/* nondescript exit code */
|
||||
expect('false.exitcode', executeCommand('false').exitcode, 1);
|
||||
|
||||
/* binary output is detected and withheld */
|
||||
const bin = executeCommand('sh', '-c', shellQuote('printf "\\001\\002\\003"'));
|
||||
expect('bin.binary', bin.binary, true);
|
||||
expect('bin.stdout', bin.stdout, null);
|
||||
|
||||
/* executeCommand() must be able to return one byte past HP_FETCH_CAP, or
|
||||
* wGETVerbose()'s "response exceeds the limit" branch can never fire: the
|
||||
* reader used to stop at 512 KiB, so every subscription between 512 KiB and
|
||||
* 5 MiB arrived at the parser as a truncated body with error: null - silently
|
||||
* truncated, and reported as complete. 600 KiB sits between the old reader
|
||||
* cap and HP_FETCH_CAP, so this fails on that code and passes on the fix. */
|
||||
const BIG_LEN = 600 * 1024;
|
||||
const big = executeCommand('sh', '-c', shellQuote('head -c ' + BIG_LEN + ' /dev/zero | tr "\\0" a'));
|
||||
expect('big.exitcode', big.exitcode, 0);
|
||||
expect('big.stdout.length', length(big.stdout || ''), BIG_LEN);
|
||||
|
||||
/* isValidPEM(): certificate vs private key, boundaries and body */
|
||||
const pem_cert = '-----BEGIN CERTIFICATE-----\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n-----END CERTIFICATE-----';
|
||||
const pem_key = '-----BEGIN RSA PRIVATE KEY-----\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n-----END RSA PRIVATE KEY-----';
|
||||
expect('pem.cert', isValidPEM(pem_cert, false), true);
|
||||
expect('pem.cert-as-key', isValidPEM(pem_cert, true), false);
|
||||
expect('pem.key', isValidPEM(pem_key, true), true);
|
||||
expect('pem.key-as-cert', isValidPEM(pem_key, false), false);
|
||||
expect('pem.garbage', isValidPEM('not a pem at all', false), false);
|
||||
expect('pem.empty', isValidPEM('', false), false);
|
||||
|
||||
/* wGETVerbose() must not hand wget an option the target's wget does not have.
|
||||
* It used to pass --max-filesize, which busybox wget does not support: wget
|
||||
* exited 2 with "unrecognized option" before making a request, so every
|
||||
* subscription fetch failed while the test suite stayed green (the fetcher
|
||||
* tests mock wGETVerbose itself). A closed local port makes the fetch fail
|
||||
* fast either way; what is asserted is that it fails for a network reason and
|
||||
* not a usage one. */
|
||||
const wget = wGETVerbose('http://127.0.0.1:1/never');
|
||||
expect('wget.not-a-usage-error',
|
||||
match(wget.error || '', /unrecognized option|invalid option|Usage:/) == null, true);
|
||||
expect('wget.reports-a-reason', length(wget.error || '') > 0, true);
|
||||
expect('wget.no-content-on-failure', wget.content, null);
|
||||
|
||||
/* Review H3: an HTTP-level wget failure echoes the target, query string and
|
||||
* all, so `<URL>?token=secret: 404 Not Found` is what would reach the log.
|
||||
* A *connection* failure is the cheap case to reproduce here, but wget prints
|
||||
* only `failed: Connection refused.` for it - no URL - so this fetch proves
|
||||
* the end-to-end path hands back no token, not that redaction ran. The
|
||||
* redactReason() block below covers redaction itself, using the shapes wget
|
||||
* actually emits. */
|
||||
const tokwget = wGETVerbose('http://127.0.0.1:1/?token=secret');
|
||||
expect('wget.token-not-in-error',
|
||||
match(tokwget.error || '', /token=secret/) == null, true);
|
||||
|
||||
/* redactReason(): central redaction that protects every wGETVerbose caller.
|
||||
* Tested in isolation so the assertion does not depend on wget being
|
||||
* present - this runs anywhere ucode runs. */
|
||||
{
|
||||
/* Canonical wget failure shape: '<URL>: <reason>' - the URL must lose
|
||||
* its query string and userinfo. */
|
||||
const r1 = redactReason('https://user:tok@host.example.com/path?q=token=secret: Bad port \'80080\'.');
|
||||
expect('redactReason.query',
|
||||
match(r1, /\?\*\*\*/) != null, true);
|
||||
expect('redactReason.userinfo',
|
||||
match(r1, /user:tok/) == null, true);
|
||||
expect('redactReason.host-preserved',
|
||||
match(r1, /host\.example\.com/) != null, true);
|
||||
expect('redactReason.reason-preserved',
|
||||
match(r1, /Bad port/) != null, true);
|
||||
|
||||
/* A second URL in the same message - both must be redacted. */
|
||||
const r2 = redactReason('first https://a.example/?token=A then https://b.example/?token=B end');
|
||||
expect('redactReason.both-redacted',
|
||||
match(r2, /token=A/) == null && match(r2, /token=B/) == null, true);
|
||||
|
||||
/* No URL at all: returned unchanged. */
|
||||
expect('redactReason.no-url-unchanged',
|
||||
redactReason('wget: bad port'), 'wget: bad port');
|
||||
|
||||
/* Empty / non-string: returned unchanged (the function is safe to
|
||||
* apply to the trimmed stderr without a guard). */
|
||||
expect('redactReason.empty', redactReason(''), '');
|
||||
expect('redactReason.null', redactReason(null), null);
|
||||
expect('redactReason.non-string', redactReason(123), 123);
|
||||
|
||||
/* URL with port: the port must survive (the path is what we keep;
|
||||
* only query and userinfo go). */
|
||||
const r3 = redactReason('wget: https://host:80080/path?q=token=secret: failed');
|
||||
expect('redactReason.port-survives',
|
||||
match(r3, /host:80080/) != null, true);
|
||||
expect('redactReason.port-query-redacted',
|
||||
match(r3, /token=secret/) == null, true);
|
||||
}
|
||||
|
||||
/* descriptors must not leak across calls */
|
||||
const before = fd_count();
|
||||
for (let i = 0; i < 200; i++)
|
||||
executeCommand('true');
|
||||
const after = fd_count();
|
||||
|
||||
checks++;
|
||||
if (after > before) {
|
||||
printf('FAIL descriptor leak: %d -> %d after 200 calls\n', before, after);
|
||||
failures++;
|
||||
}
|
||||
|
||||
printf('%d checks, %d failures\n', checks, failures);
|
||||
exit(failures === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/ucode
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Copyright (C) 2025 ImmortalWrt.org
|
||||
*
|
||||
* Exceptional-path test for executeCommand(): the copy of homeproxy.uc staged
|
||||
* next to this file has its `system()` call replaced by `die()`, so every call
|
||||
* throws before the descriptors are read. ucode has no `finally`, so this
|
||||
* checks that the catch block still closes both descriptors.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { lsdir } from 'fs';
|
||||
import { executeCommand } from 'homeproxy';
|
||||
|
||||
let failures = 0;
|
||||
|
||||
function fd_count() {
|
||||
let n = 0;
|
||||
for (let _entry in lsdir('/proc/self/fd'))
|
||||
n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
const before = fd_count();
|
||||
let caught = 0;
|
||||
|
||||
for (let i = 0; i < 50; i++) {
|
||||
try {
|
||||
executeCommand('true');
|
||||
} catch (e) {
|
||||
caught++;
|
||||
}
|
||||
}
|
||||
|
||||
const after = fd_count();
|
||||
|
||||
if (caught !== 50) {
|
||||
printf('FAIL: expected 50 propagated exceptions, got %d\n', caught);
|
||||
failures++;
|
||||
}
|
||||
|
||||
if (after > before) {
|
||||
printf('FAIL descriptor leak on the exceptional path: %d -> %d\n', before, after);
|
||||
failures++;
|
||||
}
|
||||
|
||||
if (failures === 0)
|
||||
printf('PASS: injected failure propagated and both descriptors were closed\n');
|
||||
|
||||
exit(failures === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,457 @@
|
||||
#!/usr/bin/ucode
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* PR-04 (Protocol Adapter Completion): unit tests for InboundFactory, the
|
||||
* server-side half of the adapter.
|
||||
*
|
||||
* The inbound golden snapshot (tests/ucode/test_golden_inbounds.sh) pins
|
||||
* what the adapter *emits* for the fixture's seven protocols. This test
|
||||
* covers the decisions that a snapshot cannot express:
|
||||
*
|
||||
* - a protocol with no users[] block (snell, shadowsocks) must not get
|
||||
* one: sing-box interprets a snell users entry as an extra user key
|
||||
* and rejects the config outright ("snell: bad user key")
|
||||
* - vless / vmess carry flow / alterId per user, never top-level
|
||||
* - the snell listener set omits udp_fragment / udp_timeout / network
|
||||
* even when the section carries them
|
||||
* - REQUIRED_INBOUND_CREDENTIALS is enforced by problems()/buildable()
|
||||
* - the server-only TLS tail reaches buildTLSObject() under the UCI
|
||||
* names it reads (key_path, ech_key, reality, ACME)
|
||||
* - hysteria v1 emits obfs as a string while hysteria2 emits the
|
||||
* {type, password, ...} object
|
||||
*
|
||||
* Run through tests/ucode/run.sh, which stages config/ + parser/ next to
|
||||
* the homeproxy test double.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { Inbound, INBOUND_CREDENTIALS, INBOUND_OPTIONS } from './config/model.uc';
|
||||
import { InboundFactory } from './config/adapter.uc';
|
||||
|
||||
let failures = 0, checks = 0;
|
||||
|
||||
function expect(name, actual, expected) {
|
||||
checks++;
|
||||
if (sprintf('%J', actual) !== sprintf('%J', expected)) {
|
||||
printf('FAIL %s: expected %J, got %J\n', name, expected, actual);
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
|
||||
function not_in(name, obj, key) {
|
||||
checks++;
|
||||
if (key in obj) {
|
||||
printf('FAIL %s: %s should be absent, got %J\n', name, key, obj[key]);
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
|
||||
/* Build an Inbound the way the Loader would, from a flat option map.
|
||||
* `opts` uses UCI option names; the tables in model.uc do the renaming,
|
||||
* so this helper exercises the same canonical boundary the Loader does. */
|
||||
function make_inbound(id, type, opts) {
|
||||
const get = (name) => (name in opts) ? opts[name] : null;
|
||||
const credentials = {};
|
||||
const protocol_options = {};
|
||||
|
||||
for (let canonical, uci in (INBOUND_CREDENTIALS[type] || {}))
|
||||
credentials[canonical] = get(uci);
|
||||
|
||||
for (let canonical, uci in (INBOUND_OPTIONS[type] || {}))
|
||||
protocol_options[canonical] = get(uci);
|
||||
|
||||
const tls = {
|
||||
enabled: get('tls'),
|
||||
server_name: get('tls_sni'),
|
||||
insecure: get('tls_insecure'),
|
||||
alpn: get('tls_alpn'),
|
||||
min_version: get('tls_min_version'),
|
||||
max_version: get('tls_max_version'),
|
||||
handshake_timeout: get('tls_handshake_timeout'),
|
||||
cipher_suites: get('tls_cipher_suites'),
|
||||
cert_path: get('tls_cert_path'),
|
||||
ech: {
|
||||
enabled: get('tls_ech'),
|
||||
config: get('tls_ech_config'),
|
||||
config_path: get('tls_ech_config_path')
|
||||
},
|
||||
utls: { fingerprint: get('tls_utls') },
|
||||
reality: {
|
||||
enabled: get('tls_reality'),
|
||||
public_key: get('tls_reality_public_key'),
|
||||
short_id: get('tls_reality_short_id')
|
||||
}
|
||||
};
|
||||
|
||||
return Inbound.create({
|
||||
id: id,
|
||||
name: opts.label || id,
|
||||
type: type,
|
||||
enabled: true,
|
||||
address: get('address'),
|
||||
port: get('port'),
|
||||
firewall: get('firewall'),
|
||||
common: {
|
||||
bind_interface: get('bind_interface'),
|
||||
reuse_addr: get('reuse_addr'),
|
||||
tcp_fast_open: get('tcp_fast_open'),
|
||||
tcp_multi_path: get('tcp_multi_path'),
|
||||
udp_fragment: get('udp_fragment'),
|
||||
udp_timeout: get('udp_timeout'),
|
||||
network: get('network')
|
||||
},
|
||||
credentials: credentials,
|
||||
tls: tls,
|
||||
tls_server: {
|
||||
key_path: get('tls_key_path'),
|
||||
ech_key: get('tls_ech_key'),
|
||||
reality_private_key: get('tls_reality_private_key'),
|
||||
reality_max_time_difference: get('tls_reality_max_time_difference'),
|
||||
reality_server_addr: get('tls_reality_server_addr'),
|
||||
reality_server_port: get('tls_reality_server_port'),
|
||||
acme: get('tls_acme'),
|
||||
acme_domain: get('tls_acme_domain'),
|
||||
acme_email: get('tls_acme_email'),
|
||||
acme_provider: get('tls_acme_provider'),
|
||||
dns01_challenge: get('tls_dns01_challenge'),
|
||||
dns01_provider: get('tls_dns01_provider')
|
||||
},
|
||||
transport: {
|
||||
type: get('transport'),
|
||||
host: get('ws_host') || get('httpupgrade_host') || get('http_host'),
|
||||
path: get('ws_path') || get('http_path'),
|
||||
headers: get('ws_host') ? { Host: get('ws_host') } : null,
|
||||
service_name: get('grpc_servicename')
|
||||
},
|
||||
multiplex: {
|
||||
enabled: get('multiplex'),
|
||||
padding: get('multiplex_padding'),
|
||||
brutal: {
|
||||
enabled: get('multiplex_brutal'),
|
||||
up_mbps: get('multiplex_brutal_up'),
|
||||
down_mbps: get('multiplex_brutal_down')
|
||||
}
|
||||
},
|
||||
protocol_options: protocol_options
|
||||
});
|
||||
}
|
||||
|
||||
/* --- snell: no users[], reduced listener set ------------------------- */
|
||||
|
||||
{
|
||||
/* Deliberately set the fields snell must not be given, so the test
|
||||
* fails if INBOUND_COMMON_OMIT is ever dropped. */
|
||||
const snell = make_inbound('s_snell', 'snell', {
|
||||
port: '8448',
|
||||
password: 'pskvalue',
|
||||
snell_version: '5',
|
||||
snell_obfs_mode: 'http',
|
||||
udp_fragment: '1',
|
||||
udp_timeout: '300',
|
||||
network: 'tcp'
|
||||
});
|
||||
const out = InboundFactory.create(snell);
|
||||
|
||||
expect('snell: type', out.type, 'snell');
|
||||
expect('snell: tag', out.tag, 'cfg-s_snell-in');
|
||||
expect('snell: listen defaults to ::', out.listen, '::');
|
||||
expect('snell: listen_port', out.listen_port, 8448);
|
||||
expect('snell: psk', out.psk, 'pskvalue');
|
||||
expect('snell: version', out.version, 5);
|
||||
expect('snell: obfs_mode', out.obfs_mode, 'http');
|
||||
not_in('snell', out, 'users');
|
||||
not_in('snell', out, 'udp_fragment');
|
||||
not_in('snell', out, 'udp_timeout');
|
||||
not_in('snell', out, 'network');
|
||||
/* no `mode`: v6-only, sing-box 1.14 rejects it. */
|
||||
not_in('snell', out, 'mode');
|
||||
}
|
||||
|
||||
/* --- shadowsocks: no users[], top-level method + password ------------ */
|
||||
|
||||
{
|
||||
const ss = make_inbound('s_ss', 'shadowsocks', {
|
||||
address: '10.0.0.1',
|
||||
port: '8447',
|
||||
password: 'secret',
|
||||
shadowsocks_encrypt_method: 'aes-256-gcm',
|
||||
network: 'tcp',
|
||||
udp_timeout: '300'
|
||||
});
|
||||
const out = InboundFactory.create(ss);
|
||||
|
||||
expect('ss: listen honours the address', out.listen, '10.0.0.1');
|
||||
expect('ss: method', out.method, 'aes-256-gcm');
|
||||
expect('ss: password', out.password, 'secret');
|
||||
expect('ss: network is kept', out.network, 'tcp');
|
||||
expect('ss: udp_timeout is a duration', out.udp_timeout, '300s');
|
||||
not_in('ss', out, 'users');
|
||||
}
|
||||
|
||||
/* --- vless: flow is per-user, never top-level ------------------------ */
|
||||
|
||||
{
|
||||
const vless = make_inbound('s_vless', 'vless', {
|
||||
port: '443',
|
||||
uuid: 'uuid-x',
|
||||
vless_flow: 'xtls-rprx-vision',
|
||||
tls: '1',
|
||||
tls_sni: 'v.example.com'
|
||||
});
|
||||
const out = InboundFactory.create(vless);
|
||||
|
||||
expect('vless: user name', out.users[0].name, 'cfg-s_vless-server');
|
||||
expect('vless: user uuid', out.users[0].uuid, 'uuid-x');
|
||||
expect('vless: user flow', out.users[0].flow, 'xtls-rprx-vision');
|
||||
not_in('vless', out, 'flow');
|
||||
not_in('vless', out, 'uuid');
|
||||
expect('vless: tls enabled', out.tls.enabled, true);
|
||||
expect('vless: tls server_name', out.tls.server_name, 'v.example.com');
|
||||
}
|
||||
|
||||
/* --- vmess: alterId is per-user, never top-level --------------------- */
|
||||
|
||||
{
|
||||
const vmess = make_inbound('s_vmess', 'vmess', {
|
||||
port: '443',
|
||||
uuid: 'uuid-y',
|
||||
vmess_alterid: '1'
|
||||
});
|
||||
const out = InboundFactory.create(vmess);
|
||||
|
||||
expect('vmess: user alterId', out.users[0].alterId, 1);
|
||||
not_in('vmess', out, 'alterId');
|
||||
not_in('vmess', out, 'alter_id');
|
||||
|
||||
/* vmess_alterid='0' is the modern (AEAD) setting, but the shared
|
||||
* strToInt() helper maps '0' to null (`int('0') || null`), so the
|
||||
* field is omitted and sing-box applies its own default. That is
|
||||
* pre-existing behaviour of the common helper, not something PR-04
|
||||
* changes; pinned here so a future strToInt() fix shows up as a
|
||||
* deliberate diff rather than a surprise. */
|
||||
const vmess0 = InboundFactory.create(make_inbound('s_vmess0', 'vmess', {
|
||||
port: '443',
|
||||
uuid: 'uuid-y',
|
||||
vmess_alterid: '0'
|
||||
}));
|
||||
not_in('vmess0: user alterId', vmess0.users[0], 'alterId');
|
||||
}
|
||||
|
||||
/* --- http / mixed / socks / naive: user has no name ------------------ */
|
||||
|
||||
{
|
||||
const http = make_inbound('s_http', 'http', {
|
||||
port: '8080',
|
||||
username: 'u',
|
||||
password: 'p'
|
||||
});
|
||||
const out = InboundFactory.create(http);
|
||||
|
||||
expect('http: user username', out.users[0].username, 'u');
|
||||
expect('http: user has no name', out.users[0].name, null);
|
||||
}
|
||||
|
||||
/* --- hysteria v1 vs hysteria2 obfs shape ----------------------------- */
|
||||
|
||||
{
|
||||
const hy1 = make_inbound('s_hy1', 'hysteria', {
|
||||
port: '36712',
|
||||
hysteria_auth_type: 'string',
|
||||
hysteria_auth_payload: 'secret',
|
||||
hysteria_obfs_password: 'obfspass',
|
||||
hysteria_up_mbps: '100',
|
||||
hysteria_down_mbps: '500',
|
||||
hysteria_masquerade: 'https://example.com',
|
||||
tls: '1',
|
||||
tls_sni: 'h.example.com'
|
||||
});
|
||||
const out = InboundFactory.create(hy1);
|
||||
|
||||
expect('hy1: obfs is a plain string', out.obfs, 'obfspass');
|
||||
expect('hy1: up_mbps', out.up_mbps, 100);
|
||||
expect('hy1: down_mbps', out.down_mbps, 500);
|
||||
expect('hy1: masquerade', out.masquerade, 'https://example.com');
|
||||
expect('hy1: user auth_str', out.users[0].auth_str, 'secret');
|
||||
not_in('hy1: user auth', out.users[0], 'auth');
|
||||
}
|
||||
|
||||
{
|
||||
const hy2 = make_inbound('s_hy2', 'hysteria2', {
|
||||
port: '8445',
|
||||
password: 'secret',
|
||||
hysteria_obfs_type: 'salamander',
|
||||
hysteria_obfs_password: 'obfspass',
|
||||
hysteria_obfs_min_packet_size: '512',
|
||||
hysteria_obfs_max_packet_size: '1200'
|
||||
});
|
||||
const out = InboundFactory.create(hy2);
|
||||
|
||||
expect('hy2: obfs.type', out.obfs.type, 'salamander');
|
||||
expect('hy2: obfs.password', out.obfs.password, 'obfspass');
|
||||
expect('hy2: obfs.min_packet_size', out.obfs.min_packet_size, 512);
|
||||
expect('hy2: obfs.max_packet_size', out.obfs.max_packet_size, 1200);
|
||||
/* auth is not modelled on hysteria2's options row, so neither
|
||||
* spelling is emitted. */
|
||||
not_in('hy2: user auth', out.users[0], 'auth');
|
||||
not_in('hy2: user auth_str', out.users[0], 'auth_str');
|
||||
}
|
||||
|
||||
/* --- tuic ------------------------------------------------------------ */
|
||||
|
||||
{
|
||||
const tuic = make_inbound('s_tuic', 'tuic', {
|
||||
port: '8449',
|
||||
uuid: 'uuid-z',
|
||||
password: 'pw',
|
||||
tuic_congestion_control: 'bbr',
|
||||
tuic_auth_timeout: '3',
|
||||
tuic_enable_zero_rtt: '1',
|
||||
tuic_heartbeat: '10',
|
||||
tls: '1',
|
||||
tls_sni: 't.example.com'
|
||||
});
|
||||
const out = InboundFactory.create(tuic);
|
||||
|
||||
expect('tuic: congestion_control', out.congestion_control, 'bbr');
|
||||
expect('tuic: auth_timeout', out.auth_timeout, '3s');
|
||||
expect('tuic: zero_rtt_handshake', out.zero_rtt_handshake, true);
|
||||
expect('tuic: heartbeat', out.heartbeat, '10s');
|
||||
expect('tuic: user uuid', out.users[0].uuid, 'uuid-z');
|
||||
}
|
||||
|
||||
/* --- anytls: padding_scheme ----------------------------------------- */
|
||||
|
||||
{
|
||||
const anytls = make_inbound('s_anytls', 'anytls', {
|
||||
port: '8444',
|
||||
password: 'secret',
|
||||
anytls_padding_scheme: 'stop=8'
|
||||
});
|
||||
const out = InboundFactory.create(anytls);
|
||||
|
||||
expect('anytls: padding_scheme', out.padding_scheme, 'stop=8');
|
||||
expect('anytls: user password', out.users[0].password, 'secret');
|
||||
}
|
||||
|
||||
/* --- multiplex (server shape: no dialling knobs) -------------------- */
|
||||
|
||||
{
|
||||
const trojan = make_inbound('s_trojan', 'trojan', {
|
||||
port: '8446',
|
||||
password: 'secret',
|
||||
multiplex: '1',
|
||||
multiplex_padding: '1'
|
||||
});
|
||||
const out = InboundFactory.create(trojan);
|
||||
|
||||
expect('trojan: multiplex enabled', out.multiplex.enabled, true);
|
||||
expect('trojan: multiplex padding', out.multiplex.padding, true);
|
||||
/* The server side has no protocol / max_connections / min_streams /
|
||||
* max_streams: those are client dialling knobs. */
|
||||
not_in('trojan: multiplex', out.multiplex, 'protocol');
|
||||
not_in('trojan: multiplex', out.multiplex, 'max_connections');
|
||||
not_in('trojan: multiplex', out.multiplex, 'min_streams');
|
||||
not_in('trojan: multiplex', out.multiplex, 'max_streams');
|
||||
}
|
||||
|
||||
/* --- the server-only TLS tail reaches buildTLSObject() --------------- */
|
||||
|
||||
{
|
||||
const inbound = make_inbound('s_tls', 'vless', {
|
||||
port: '443',
|
||||
uuid: 'u',
|
||||
tls: '1',
|
||||
tls_sni: 't.example.com',
|
||||
tls_key_path: '/etc/homeproxy/certs/t.key',
|
||||
tls_ech_key: 'ECHKEY',
|
||||
tls_acme: '1',
|
||||
tls_acme_domain: 't.example.com',
|
||||
tls_acme_email: 'admin@example.com',
|
||||
tls_acme_provider: 'letsencrypt'
|
||||
});
|
||||
const out = InboundFactory.create(inbound);
|
||||
|
||||
expect('tls: key_path', out.tls.key_path, '/etc/homeproxy/certs/t.key');
|
||||
expect('tls: ech.key', out.tls.ech.key, ['ECHKEY']);
|
||||
expect('tls: acme type', out.tls.certificate_provider.type, 'acme');
|
||||
expect('tls: acme domain is an array', out.tls.certificate_provider.domain, ['t.example.com']);
|
||||
expect('tls: acme email', out.tls.certificate_provider.email, 'admin@example.com');
|
||||
expect('tls: acme provider', out.tls.certificate_provider.provider, 'letsencrypt');
|
||||
/* A client-side field must not appear on a server inbound. */
|
||||
not_in('tls', out.tls, 'insecure');
|
||||
not_in('tls', out.tls, 'utls');
|
||||
}
|
||||
|
||||
{
|
||||
/* reality: the server side gets the private key + the handshake
|
||||
* address, the client side's public key stays out. */
|
||||
const inbound = make_inbound('s_reality', 'vless', {
|
||||
port: '443',
|
||||
uuid: 'u',
|
||||
tls: '1',
|
||||
tls_reality: '1',
|
||||
tls_sni: 'r.example.com',
|
||||
tls_reality_private_key: 'PRIVKEY',
|
||||
tls_reality_short_id: 'abcd',
|
||||
tls_reality_server_addr: 'hw.example.com',
|
||||
tls_reality_server_port: '443'
|
||||
});
|
||||
const out = InboundFactory.create(inbound);
|
||||
|
||||
expect('reality: private_key', out.tls.reality.private_key, 'PRIVKEY');
|
||||
expect('reality: short_id', out.tls.reality.short_id, 'abcd');
|
||||
expect('reality: handshake server', out.tls.reality.handshake.server, 'hw.example.com');
|
||||
expect('reality: handshake server_port', out.tls.reality.handshake.server_port, 443);
|
||||
not_in('reality', out.tls.reality, 'public_key');
|
||||
}
|
||||
|
||||
/* --- validation ------------------------------------------------------ */
|
||||
|
||||
expect('vless without uuid is not buildable',
|
||||
InboundFactory.buildable(make_inbound('x', 'vless', { port: '443' })), false);
|
||||
expect('vless with uuid is buildable',
|
||||
InboundFactory.buildable(make_inbound('x', 'vless', { port: '443', uuid: 'u' })), true);
|
||||
expect('snell without psk is not buildable',
|
||||
InboundFactory.buildable(make_inbound('x', 'snell', { port: '443' })), false);
|
||||
expect('snell with psk is buildable',
|
||||
InboundFactory.buildable(make_inbound('x', 'snell', { port: '443', password: 'k' })), true);
|
||||
expect('shadowsocks without password is not buildable',
|
||||
InboundFactory.buildable(make_inbound('x', 'shadowsocks', { port: '443' })), false);
|
||||
expect('http needs no credential',
|
||||
InboundFactory.buildable(make_inbound('x', 'http', { port: '8080' })), true);
|
||||
|
||||
{
|
||||
const bad = InboundFactory.tryCreate(make_inbound('s_bad', 'vless', { port: '443' }));
|
||||
expect('tryCreate returns no inbound on problems', bad.inbound, null);
|
||||
expect('tryCreate names the missing credential',
|
||||
length(filter(bad.problems, (p) => match(p, /requires uuid/))), 1);
|
||||
}
|
||||
{
|
||||
/* The port check is protocol-agnostic and lives on Inbound.validate(). */
|
||||
const bad = InboundFactory.tryCreate(make_inbound('s_badport', 'http', { port: '99999' }));
|
||||
expect('tryCreate rejects an out-of-range port', bad.inbound, null);
|
||||
expect('tryCreate reports the port',
|
||||
length(filter(bad.problems, (p) => match(p, /invalid port/))), 1);
|
||||
}
|
||||
|
||||
/* --- transport ------------------------------------------------------- */
|
||||
|
||||
{
|
||||
const ws = make_inbound('s_ws', 'vless', {
|
||||
port: '443',
|
||||
uuid: 'u',
|
||||
transport: 'ws',
|
||||
ws_host: 'a.example.com',
|
||||
ws_path: '/ws'
|
||||
});
|
||||
const out = InboundFactory.create(ws);
|
||||
|
||||
expect('transport: type', out.transport.type, 'ws');
|
||||
expect('transport: path', out.transport.path, '/ws');
|
||||
expect('transport: host from headers', out.transport.host, 'a.example.com');
|
||||
}
|
||||
|
||||
printf('%d checks, %d failures\n', checks, failures);
|
||||
exit(failures === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,170 @@
|
||||
#!/bin/sh
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# Stage PHASE 9 close-out item: migrate_config.uc has never had a
|
||||
# direct test. The migration runs on every UCI commit and was only
|
||||
# ever exercised by full upgrade tests on a target, so a developer
|
||||
# could break a step and never see it fail.
|
||||
#
|
||||
# The runner stages a sandboxed UCI dir with a file that needs every
|
||||
# migration step it can see at once:
|
||||
#
|
||||
# infra.china_dns_port -> deleted (chinadns-ng removed)
|
||||
# config.china_dns_server -> 'wan_114' mapped to 114.114.114.114
|
||||
# infra.github_token -> moved to config
|
||||
# infra.ntp_server -> defaulted to 'nil'
|
||||
# infra.tun_gso -> deleted (sb 1.11)
|
||||
# config.routing_port='all' -> deleted
|
||||
# dns.default_server='block-dns' -> 'default-dns' + final-block rule
|
||||
# dns.independent_cache -> deleted (sb 1.14)
|
||||
# dns.cache_file_store_rdrc -> renamed to cache_file_store_dns
|
||||
# dns.cache_file_rdrc_timeout -> deleted
|
||||
# dns_server.address (legacy) -> split into type/server/server_port
|
||||
# dns_server.strategy/client_subnet -> moved to the referencing rule
|
||||
# dns_server.address='rcode://x' -> predefined rcode rule
|
||||
# dns_rule.server='block-dns' -> action='reject'
|
||||
# dns_rule.rule_set_ipcidr_match_source -> renamed (sb 1.10)
|
||||
# routing_rule.outbound='block-out' -> action='reject' (sb 1.11)
|
||||
# server.auto_firewall='1' -> per-server firewall='1'
|
||||
# server.sniff_override -> deleted (sb 1.11)
|
||||
#
|
||||
# The cursor override flows through HP_MIGRATE_UCI_DIR (see the
|
||||
# comment at the top of migrate_config.uc); production keeps the
|
||||
# default cursor() because the global is unset there.
|
||||
#
|
||||
# Usage: sh tests/ucode/test_migrate_config.sh <repo-root> [work-dir]
|
||||
|
||||
ROOT="${1:-.}"
|
||||
WORK="${2:-/tmp/hp-migrate-test}"
|
||||
|
||||
ROOT="$(cd "$ROOT" && pwd)"
|
||||
FAILED=0
|
||||
|
||||
SANDBOX="$WORK/sandbox"
|
||||
STAGE="$WORK/scripts"
|
||||
|
||||
rm -rf "$WORK"
|
||||
mkdir -p "$SANDBOX" "$STAGE"
|
||||
|
||||
# cursor(dir) reads <dir>/homeproxy directly (NOT <dir>/config/homeproxy).
|
||||
cat > "$SANDBOX/homeproxy" <<'EOF'
|
||||
config homeproxy 'config'
|
||||
option log_level ''
|
||||
option china_dns_server 'wan_114'
|
||||
option routing_port 'all'
|
||||
config homeproxy 'infra'
|
||||
option self_mark '100'
|
||||
option china_dns_port '5354'
|
||||
option github_token 'secret'
|
||||
config homeproxy 'dns'
|
||||
option default_server 'block-dns'
|
||||
option independent_cache '1'
|
||||
option cache_file_store_rdrc '1'
|
||||
option cache_file_rdrc_timeout '60'
|
||||
config homeproxy 'server'
|
||||
option auto_firewall '1'
|
||||
config dns_server 'ds_default'
|
||||
option address 'tls://1.1.1.1:853'
|
||||
option strategy 'prefer_ipv4'
|
||||
option client_subnet '1.2.3.0/24'
|
||||
config dns_server 'ds_rcode'
|
||||
option address 'rcode://name_error'
|
||||
config dns_rule 'dr_block'
|
||||
option server 'block-dns'
|
||||
config dns_rule 'dr_uses_default'
|
||||
option server 'ds_default'
|
||||
config dns_rule 'dr_stale'
|
||||
option rule_set_ipcidr_match_source '1'
|
||||
config routing_rule 'rr_block'
|
||||
option outbound 'block-out'
|
||||
config server 'srv_one'
|
||||
option type 'shadowsocks'
|
||||
option sniff_override '1'
|
||||
EOF
|
||||
|
||||
# Stage the mock homeproxy + migrate_config.uc + test driver next to
|
||||
# each other so the bare-name imports resolve through -L.
|
||||
#
|
||||
# migrate_config.uc runs at import time and writes through a UCI
|
||||
# cursor that defaults to /etc/config. The test must not touch the
|
||||
# real UCI tree, so the staged *copy* is rewritten to take the
|
||||
# sandbox dir from ARGV[0]. Production source keeps the plain
|
||||
# cursor() call - the project moved away from test seams in
|
||||
# production code when HP_TEST_HOOK was removed, and re-adding one
|
||||
# would go backwards.
|
||||
cp "$ROOT/tests/ucode/mocks/homeproxy.uc" "$STAGE/"
|
||||
cp "$ROOT/root/etc/homeproxy/scripts/migrate_config.uc" "$STAGE/"
|
||||
cp "$ROOT/tests/ucode/test_migrate_config.uc" "$STAGE/"
|
||||
|
||||
# The shebang is dropped in the staged copy: migrate_config.uc is a
|
||||
# program in production (`ucode -S .../migrate_config.uc`, where the
|
||||
# shebang is fine), but this test *imports* it, and the target ucode
|
||||
# rejects a `#!` line in a module ("Unexpected character" at line 1,
|
||||
# followed by cascading lexer errors).
|
||||
# The crontab path is a literal in the source (/etc/crontabs/root). Point the
|
||||
# staged copy at the sandbox too, or the run would edit the device's real
|
||||
# crontab - the migration is supposed to do that once on a real upgrade, but a
|
||||
# test must not.
|
||||
sed -e '1{/^#!\/usr\/bin\/ucode$/d;}' \
|
||||
-e 's|^const uci = cursor();$|const uci = cursor(ARGV[0]);|' \
|
||||
-e "s|'/etc/crontabs/root'|'$SANDBOX/crontab'|" \
|
||||
"$STAGE/migrate_config.uc" > "$STAGE/migrate_config.uc.new"
|
||||
|
||||
# Hard guard: if an anchor ever stops matching (someone reformats the
|
||||
# line), the sed silently no-ops. Losing the cursor redirect would make the
|
||||
# migration run against the real /etc/config - which on a target would
|
||||
# rewrite the live configuration; losing the shebang strip means the module
|
||||
# cannot be imported at all. Refuse to run instead.
|
||||
if ! grep -q 'cursor(ARGV\[0\])' "$STAGE/migrate_config.uc.new"; then
|
||||
echo "FAIL: migrate_config regressions: could not redirect the UCI cursor"
|
||||
echo " (the 'const uci = cursor();' anchor no longer matches)"
|
||||
exit 1
|
||||
fi
|
||||
if head -1 "$STAGE/migrate_config.uc.new" | grep -q '^#!'; then
|
||||
echo "FAIL: migrate_config regressions: the shebang was not stripped"
|
||||
echo " (the '#!/usr/bin/ucode' anchor no longer matches)"
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q "'$SANDBOX/crontab'" "$STAGE/migrate_config.uc.new"; then
|
||||
echo "FAIL: migrate_config regressions: could not redirect the crontab path"
|
||||
echo " (the \"'/etc/crontabs/root'\" anchor no longer matches)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# A crontab that still carries the legacy entry, plus one line that must
|
||||
# survive. The migration has to remove exactly the former.
|
||||
cat > "$SANDBOX/crontab" <<'CRONTAB'
|
||||
0 3 * * * /etc/homeproxy/scripts/update_crond.sh
|
||||
*/5 * * * * /usr/bin/echo keep-me
|
||||
CRONTAB
|
||||
mv "$STAGE/migrate_config.uc.new" "$STAGE/migrate_config.uc"
|
||||
|
||||
if ( cd "$STAGE" && ucode -L "$STAGE" test_migrate_config.uc "$SANDBOX" ); then
|
||||
echo "PASS: migrate_config regressions"
|
||||
else
|
||||
echo "FAIL: migrate_config regressions"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
# The crontab cleanup, asserted from the shell so the .uc test does not have to
|
||||
# know about files. The old form ran `sed -i ... 2>/dev/null`, so on a host
|
||||
# whose sed has no bare -i (BSD) it failed silently and left the entry behind -
|
||||
# and the marker was set anyway, so the migration never retried.
|
||||
if grep -q 'update_crond.sh' "$SANDBOX/crontab"; then
|
||||
echo "FAIL: migrate_config regressions: the legacy update_crond.sh crontab entry survived"
|
||||
grep -n 'update_crond.sh' "$SANDBOX/crontab" | head -2
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
if ! grep -q 'keep-me' "$SANDBOX/crontab"; then
|
||||
echo "FAIL: migrate_config regressions: the crontab rewrite dropped an unrelated line"
|
||||
cat "$SANDBOX/crontab"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
if ! grep -q "option crontab '1'" "$SANDBOX/homeproxy"; then
|
||||
echo "FAIL: migrate_config regressions: the crontab marker was not set after a successful edit"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
exit $FAILED
|
||||
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/ucode
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Driver for tests/ucode/test_migrate_config.sh.
|
||||
*
|
||||
* migrate_config.uc has no exports: the migration executes at import
|
||||
* time and writes through a UCI cursor. The staged copy of
|
||||
* migrate_config.uc has been rewritten by the runner so its cursor
|
||||
* reads ARGV[0] (the sandbox dir) instead of /etc/config - so this
|
||||
* driver just imports it and lets the side effect run.
|
||||
*
|
||||
* The assertions below then re-read the sandbox and check the
|
||||
* post-state step by step, so a failure names the migration step
|
||||
* that regressed rather than "migrate broke something".
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { cursor } from 'uci';
|
||||
|
||||
import 'migrate_config';
|
||||
|
||||
const uci = cursor(ARGV[0]);
|
||||
uci.load('homeproxy');
|
||||
|
||||
let failures = 0,
|
||||
checks = 0;
|
||||
|
||||
function expect(name, actual, want) {
|
||||
checks++;
|
||||
if (sprintf('%J', actual) !== sprintf('%J', want)) {
|
||||
printf('FAIL %s: expected %J, got %J\n', name, want, actual);
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- infra section ---------------------------------------------------- */
|
||||
|
||||
/* chinadns-ng removed: china_dns_port deleted. */
|
||||
expect('infra.china_dns_port deleted', uci.get('homeproxy', 'infra', 'china_dns_port'), null);
|
||||
|
||||
/* github_token moved from infra to config. */
|
||||
expect('config.github_token set', uci.get('homeproxy', 'config', 'github_token'), 'secret');
|
||||
expect('infra.github_token deleted', uci.get('homeproxy', 'infra', 'github_token'), null);
|
||||
|
||||
/* ntp_server introduced, defaults to the 'nil' sentinel. */
|
||||
expect('infra.ntp_server default', uci.get('homeproxy', 'infra', 'ntp_server'), 'nil');
|
||||
|
||||
/* tun_gso deprecated in sing-box 1.11: absent here, so nothing to do -
|
||||
* assert it stays absent rather than flapping to a default. */
|
||||
expect('infra.tun_gso stays absent', uci.get('homeproxy', 'infra', 'tun_gso'), null);
|
||||
|
||||
/* --- config section --------------------------------------------------- */
|
||||
|
||||
/* single-value chinadns server: legacy 'wan_114' maps to 114.114.114.114. */
|
||||
expect('config.china_dns_server→114', uci.get('homeproxy', 'config', 'china_dns_server'), '114.114.114.114');
|
||||
|
||||
/* log_level introduced, defaults to 'warn'. */
|
||||
expect('config.log_level default', uci.get('homeproxy', 'config', 'log_level'), 'warn');
|
||||
|
||||
/* routing_port='all' means "every port" = no option at all. */
|
||||
expect('config.routing_port deleted', uci.get('homeproxy', 'config', 'routing_port'), null);
|
||||
|
||||
/* --- dns section ------------------------------------------------------ */
|
||||
|
||||
/* default_server='block-dns' becomes 'default-dns' plus an appended
|
||||
* reject rule that reproduces the old "block everything" behaviour. */
|
||||
expect('dns.default_server→default-dns', uci.get('homeproxy', 'dns', 'default_server'), 'default-dns');
|
||||
expect('dns final-block action', uci.get('homeproxy', '_migration_dns_final_block', 'action'), 'reject');
|
||||
expect('dns final-block enabled', uci.get('homeproxy', '_migration_dns_final_block', 'enabled'), '1');
|
||||
|
||||
/* sing-box 1.14 deprecated DNS semantics. */
|
||||
expect('dns.independent_cache deleted', uci.get('homeproxy', 'dns', 'independent_cache'), null);
|
||||
expect('dns.store_rdrc→store_dns', uci.get('homeproxy', 'dns', 'cache_file_store_dns'), '1');
|
||||
expect('dns.store_rdrc deleted', uci.get('homeproxy', 'dns', 'cache_file_store_rdrc'), null);
|
||||
expect('dns.rdrc_timeout deleted', uci.get('homeproxy', 'dns', 'cache_file_rdrc_timeout'), null);
|
||||
|
||||
/* --- dns_server: legacy address form ---------------------------------- */
|
||||
|
||||
/* 'tls://1.1.1.1:853' splits into type/server/server_port, and the
|
||||
* legacy address key is dropped so the generator stops reading it. */
|
||||
expect('ds_default.type', uci.get('homeproxy', 'ds_default', 'type'), 'tls');
|
||||
expect('ds_default.server', uci.get('homeproxy', 'ds_default', 'server'), '1.1.1.1');
|
||||
expect('ds_default.server_port', uci.get('homeproxy', 'ds_default', 'server_port'), '853');
|
||||
expect('ds_default.address deleted', uci.get('homeproxy', 'ds_default', 'address'), null);
|
||||
|
||||
/* strategy / client_subnet moved out of the dns_server and into the
|
||||
* dns_rule that referenced it. */
|
||||
expect('ds_default.strategy deleted', uci.get('homeproxy', 'ds_default', 'strategy'), null);
|
||||
expect('ds_default.client_subnet deleted', uci.get('homeproxy', 'ds_default', 'client_subnet'), null);
|
||||
expect('dr_uses_default.strategy', uci.get('homeproxy', 'dr_uses_default', 'strategy'), 'prefer_ipv4');
|
||||
expect('dr_uses_default.client_subnet', uci.get('homeproxy', 'dr_uses_default', 'client_subnet'), '1.2.3.0/24');
|
||||
|
||||
/* RCode-as-address migrated into the predefined-rcode rule form; the
|
||||
* dns_server section itself is removed. */
|
||||
expect('ds_rcode section deleted', uci.get_all('homeproxy', 'ds_rcode'), null);
|
||||
|
||||
/* --- dns_rule --------------------------------------------------------- */
|
||||
|
||||
/* server='block-dns' moved into action='reject'. */
|
||||
expect('dr_block.action=reject', uci.get('homeproxy', 'dr_block', 'action'), 'reject');
|
||||
expect('dr_block.server deleted', uci.get('homeproxy', 'dr_block', 'server'), null);
|
||||
|
||||
/* rule_set_ipcidr_match_source renamed to rule_set_ip_cidr_match_source. */
|
||||
expect('dr_stale.old key deleted', uci.get('homeproxy', 'dr_stale', 'rule_set_ipcidr_match_source'), null);
|
||||
expect('dr_stale.new key set', uci.get('homeproxy', 'dr_stale', 'rule_set_ip_cidr_match_source'), '1');
|
||||
|
||||
/* a rule with no action gets the 'route' default. */
|
||||
expect('dr_uses_default.action=route', uci.get('homeproxy', 'dr_uses_default', 'action'), 'route');
|
||||
|
||||
/* --- routing_rule ----------------------------------------------------- */
|
||||
|
||||
/* outbound='block-out' moved into action='reject'. */
|
||||
expect('rr_block.action=reject', uci.get('homeproxy', 'rr_block', 'action'), 'reject');
|
||||
expect('rr_block.outbound deleted', uci.get('homeproxy', 'rr_block', 'outbound'), null);
|
||||
|
||||
/* --- server ----------------------------------------------------------- */
|
||||
|
||||
/* auto_firewall was a single global flag; it is redistributed onto
|
||||
* each server section as firewall='1'. */
|
||||
expect('srv_one.firewall set', uci.get('homeproxy', 'srv_one', 'firewall'), '1');
|
||||
expect('server.auto_firewall deleted', uci.get('homeproxy', 'server', 'auto_firewall'), null);
|
||||
|
||||
/* sniff_override deprecated in sing-box 1.11. */
|
||||
expect('srv_one.sniff_override deleted', uci.get('homeproxy', 'srv_one', 'sniff_override'), null);
|
||||
|
||||
/* server log_level introduced, defaults to 'warn'. */
|
||||
expect('server.log_level default', uci.get('homeproxy', 'server', 'log_level'), 'warn');
|
||||
|
||||
/* --- migration bookkeeping -------------------------------------------- */
|
||||
|
||||
/* the migration marker section must survive so the crontab cleanup
|
||||
* does not run twice. */
|
||||
expect('migration.crontab marker', uci.get('homeproxy', 'migration', 'crontab'), '1');
|
||||
|
||||
/* --- summary ---------------------------------------------------------- */
|
||||
|
||||
if (failures > 0)
|
||||
printf('FAIL: %d/%d checks failed\n', failures, checks);
|
||||
else
|
||||
printf('PASS: %d checks\n', checks);
|
||||
|
||||
exit(failures > 0 ? 1 : 0);
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
#!/bin/sh
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# The test doubles must still behave like the production code they copy.
|
||||
#
|
||||
# tests/ucode/mocks/homeproxy.uc says of isEmpty/decodeBase64Str/parseURL:
|
||||
# "verbatim copy so the parser tests exercise the same string handling as
|
||||
# production. Keep these in sync when homeproxy.uc changes." Nothing enforced
|
||||
# that sentence. tests/ucode/mocks/homeproxy_fetcher.uc carries the same note
|
||||
# for redactUrl, and the *security* assertion in
|
||||
# test_subscription_fetcher.uc - that a subscription token cannot reappear in
|
||||
# the log - is made against that copy, not against the production function.
|
||||
#
|
||||
# Editing the production function alone would therefore leave every test green
|
||||
# while the behaviour they claim to check had changed. This compares the two
|
||||
# implementations on a shared corpus, which is what "in sync" has to mean: the
|
||||
# mock is allowed to be reformatted (redactUrl's copy already reorders a couple
|
||||
# of statements), only the results must agree.
|
||||
#
|
||||
# validation() is deliberately NOT compared. It is a pure-ucode stand-in for
|
||||
# /sbin/validate_data, so it differs from the real thing by design; it is
|
||||
# pinned by its own unit tests instead.
|
||||
#
|
||||
# Usage: sh tests/ucode/test_mock_sync.sh <repo-root> [workdir]
|
||||
|
||||
set -u
|
||||
|
||||
ROOT="$(cd "${1:-$(dirname "$0")/../..}" && pwd)"
|
||||
WORK="${2:-/tmp/hp-mock-sync}"
|
||||
|
||||
if ! command -v ucode > "/dev/null" 2>&1; then
|
||||
echo "NOT RUN: ucode is not on PATH."
|
||||
exit 2
|
||||
fi
|
||||
|
||||
|
||||
rm -rf "$WORK"
|
||||
mkdir -p "$WORK"
|
||||
|
||||
# production parseURL() validates the hostname by shelling out to the hardcoded
|
||||
# /sbin/validate_data. On a device that binary exists; off-target it does not,
|
||||
# and production then returns null for every URL while the mock - which carries
|
||||
# its own pure-ucode validation() - parses them happily. An environment variable
|
||||
# would not help: the path is a literal in the source, so the staged copy has to
|
||||
# be rewritten, exactly as tests/ucode/test_generators.sh does it. Without this
|
||||
# the test passed on the device and failed in CI, which is how it was found.
|
||||
VALIDATE_DATA="${HP_VALIDATE_DATA:-/sbin/validate_data}"
|
||||
if [ ! -x "$VALIDATE_DATA" ] && [ -x "$ROOT/tests/toolchain/validate-data.sh" ]; then
|
||||
VALIDATE_DATA="$ROOT/tests/toolchain/validate-data.sh"
|
||||
fi
|
||||
|
||||
mkdir -p "$WORK/scripts"
|
||||
sed -e "s#/sbin/validate_data#$VALIDATE_DATA#" \
|
||||
"$ROOT/root/etc/homeproxy/scripts/homeproxy.uc" > "$WORK/scripts/homeproxy.uc"
|
||||
|
||||
cat > "$WORK/sync.uc" <<'DRIVER'
|
||||
import {
|
||||
isEmpty as p_isEmpty,
|
||||
decodeBase64Str as p_decodeBase64Str,
|
||||
redactUrl as p_redactUrl,
|
||||
parseURL as p_parseURL,
|
||||
shellQuote as p_shellQuote
|
||||
} from '@@SCRIPTS@@/homeproxy.uc';
|
||||
import {
|
||||
isEmpty as m_isEmpty,
|
||||
decodeBase64Str as m_decodeBase64Str,
|
||||
parseURL as m_parseURL,
|
||||
shellQuote as m_shellQuote
|
||||
} from '@@MOCK-HP@@';
|
||||
import { redactUrl as f_redactUrl } from '@@MOCK-FETCHER@@';
|
||||
|
||||
let checks = 0, failures = 0;
|
||||
function same(name, a, b) {
|
||||
checks++;
|
||||
const sa = sprintf('%J', a), sb = sprintf('%J', b);
|
||||
if (sa === sb) return;
|
||||
failures++;
|
||||
printf('FAIL %s: production %s vs mock %s\n', name, sa, sb);
|
||||
}
|
||||
|
||||
/* --- isEmpty ------------------------------------------------------------ */
|
||||
const empty_corpus = ['', null, 'nil', [], {}, [1], { a: 1 }, 0, 'x', '0', false, true, [ [] ]];
|
||||
for (let v in empty_corpus)
|
||||
same(sprintf('isEmpty(%J)', v), p_isEmpty(v), m_isEmpty(v));
|
||||
|
||||
/* --- decodeBase64Str ---------------------------------------------------- */
|
||||
const b64_corpus = [
|
||||
'', 'aGVsbG8=', 'aGVsbG8', 'aGVsbG8h', 'a-b_c', 'a+b/c', '!!!!',
|
||||
'AA==', 'AAA=', 'AAAA', ' aGVsbG8= ', 'not base64 at all', '===='
|
||||
];
|
||||
for (let v in b64_corpus)
|
||||
same(sprintf('decodeBase64Str(%J)', v), p_decodeBase64Str(v), m_decodeBase64Str(v));
|
||||
|
||||
/* --- shellQuote --------------------------------------------------------- */
|
||||
const quote_corpus = [
|
||||
'', 'plain', "it's", "a'b'c", 'has space', '"double"', '$var', '`cmd`',
|
||||
'; rm -rf /', 'a\\b', "mix'\"$`"
|
||||
];
|
||||
for (let v in quote_corpus)
|
||||
same(sprintf('shellQuote(%J)', v), p_shellQuote(v), m_shellQuote(v));
|
||||
|
||||
/* --- redactUrl: the fetcher mock's copy -------------------------------- */
|
||||
const url_corpus = [
|
||||
'',
|
||||
'https://host/path',
|
||||
'https://user:pass@host/path?token=secret',
|
||||
'https://user@host/path',
|
||||
'https://host/path?a=1&b=2',
|
||||
'https://host/path?a=1#frag',
|
||||
'ss://method:password@host:8388#label',
|
||||
'not a url',
|
||||
'https://host/path?',
|
||||
'://@?'
|
||||
];
|
||||
for (let v in url_corpus)
|
||||
same(sprintf('redactUrl(%J)', v), p_redactUrl(v), f_redactUrl(v));
|
||||
|
||||
/* --- parseURL: only inputs where the real validate_data and the stand-in
|
||||
* agree, since validation() is allowed to differ. ------------------------ */
|
||||
const parse_corpus = [
|
||||
'https://example.com/path',
|
||||
'https://example.com:8443/path?x=1#h',
|
||||
'http://user:pass@example.com/p',
|
||||
'https://1.2.3.4/p',
|
||||
'https://[2001:db8::1]:443/p',
|
||||
'ftp://example.com/p',
|
||||
'',
|
||||
'not a url',
|
||||
'https://',
|
||||
'https:///path'
|
||||
];
|
||||
for (let v in parse_corpus)
|
||||
same(sprintf('parseURL(%J)', v), p_parseURL(v), m_parseURL(v));
|
||||
|
||||
printf('mock sync: %d checks, %d failures\n', checks, failures);
|
||||
exit(failures ? 1 : 0);
|
||||
DRIVER
|
||||
|
||||
sed -e "s#@@SCRIPTS@@#$WORK/scripts#" \
|
||||
-e "s#@@MOCK-HP@@#$ROOT/tests/ucode/mocks/homeproxy.uc#" \
|
||||
-e "s#@@MOCK-FETCHER@@#$ROOT/tests/ucode/mocks/homeproxy_fetcher.uc#" \
|
||||
"$WORK/sync.uc" > "$WORK/sync.uc.new"
|
||||
mv -f "$WORK/sync.uc.new" "$WORK/sync.uc"
|
||||
|
||||
if ucode -L "$ROOT/root/etc/homeproxy/scripts" "$WORK/sync.uc"; then
|
||||
echo "PASS: the test doubles agree with production"
|
||||
rm -rf "$WORK"
|
||||
exit 0
|
||||
else
|
||||
echo "FAIL: a test double has drifted from the production function it copies"
|
||||
rm -rf "$WORK"
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/ucode
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Copyright (C) 2025 ImmortalWrt.org
|
||||
*
|
||||
* Unit tests for the share-link parsers in scripts/parser/. Run
|
||||
* through tests/ucode/run.sh, which stages the whole parser/ tree
|
||||
* next to the mocked homeproxy.uc.
|
||||
*
|
||||
* PR-02: parse_uri() now dispatches from scripts/parser/uri.uc,
|
||||
* pulling the per-scheme branches from parser/protocols.uc and the
|
||||
* categorised post-parse checks from parser/validator.uc. The import
|
||||
* below is left as `from 'parser/uri.uc'` so the staged work dir
|
||||
* (where parser/ is a sibling of the test) resolves identically.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { parse_uri } from 'parser/uri.uc';
|
||||
|
||||
const FEATURES = { with_quic: true, with_utls: true };
|
||||
const NO_QUIC = { with_quic: false, with_utls: true };
|
||||
const LOG = (..._args) => {};
|
||||
|
||||
let failures = 0,
|
||||
checks = 0;
|
||||
|
||||
function expect_fields(name, cfg, expected) {
|
||||
if (type(cfg) !== 'object') {
|
||||
printf('FAIL %s: expected an object, got %J\n', name, cfg);
|
||||
failures++;
|
||||
return;
|
||||
}
|
||||
|
||||
for (let key in expected) {
|
||||
checks++;
|
||||
if (sprintf('%J', cfg[key]) !== sprintf('%J', expected[key])) {
|
||||
printf('FAIL %s: %s expected %J, got %J\n', name, key, expected[key], cfg[key]);
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function expect_null(name, cfg) {
|
||||
checks++;
|
||||
if (cfg !== null) {
|
||||
printf('FAIL %s: expected null, got %J\n', name, cfg);
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
|
||||
/* anytls */
|
||||
expect_fields('anytls', parse_uri('anytls://secret@a.example.com:443?sni=a.example.com&insecure=1#AnyTLS-Node', FEATURES, LOG), {
|
||||
label: 'AnyTLS-Node', type: 'anytls', address: 'a.example.com', port: '443',
|
||||
password: 'secret', tls: '1', tls_sni: 'a.example.com', tls_insecure: '1'
|
||||
});
|
||||
|
||||
/* http / https */
|
||||
expect_fields('https', parse_uri('https://user:pass@b.example.com:8443#HTTPS-Node', FEATURES, LOG), {
|
||||
label: 'HTTPS-Node', type: 'http', address: 'b.example.com', port: '8443',
|
||||
username: 'user', password: 'pass', tls: '1'
|
||||
});
|
||||
expect_fields('http', parse_uri('http://c.example.com:8080#HTTP-Node', FEATURES, LOG), {
|
||||
label: 'HTTP-Node', type: 'http', address: 'c.example.com', port: '8080', tls: '0'
|
||||
});
|
||||
|
||||
/* hysteria */
|
||||
expect_fields('hysteria', parse_uri('hysteria://d.example.com:36712?auth=pass&peer=d.example.com&insecure=1&upmbps=100&downmbps=200&alpn=h3&protocol=udp#Hy1', FEATURES, LOG), {
|
||||
label: 'Hy1', type: 'hysteria', address: 'd.example.com', port: '36712',
|
||||
hysteria_protocol: 'udp', hysteria_auth_type: 'string', hysteria_auth_payload: 'pass',
|
||||
hysteria_up_mbps: '100', hysteria_down_mbps: '200',
|
||||
tls: '1', tls_insecure: '1', tls_sni: 'd.example.com', tls_alpn: 'h3'
|
||||
});
|
||||
|
||||
/* hysteria2 */
|
||||
expect_fields('hysteria2', parse_uri('hysteria2://pass@e.example.com:443?sni=e.example.com&obfs=salamander&obfs-password=op#Hy2', FEATURES, LOG), {
|
||||
label: 'Hy2', type: 'hysteria2', address: 'e.example.com', port: '443',
|
||||
password: 'pass', hysteria_obfs_type: 'salamander', hysteria_obfs_password: 'op',
|
||||
tls: '1', tls_insecure: '0', tls_sni: 'e.example.com'
|
||||
});
|
||||
expect_fields('hy2-alias', parse_uri('hy2://pass@e.example.com:443#Hy2Alias', FEATURES, LOG), {
|
||||
label: 'Hy2Alias', type: 'hysteria2', address: 'e.example.com', port: '443'
|
||||
});
|
||||
|
||||
/* snell */
|
||||
expect_fields('snell', parse_uri('snell://f.example.com:443?psk=pskvalue&version=4&obfs=http&obfs-host=bing.com&reuse=1#Snell', FEATURES, LOG), {
|
||||
label: 'Snell', type: 'snell', address: 'f.example.com', port: '443',
|
||||
password: 'pskvalue', snell_version: '4', snell_obfs_mode: 'http',
|
||||
snell_obfs_host: 'bing.com', snell_reuse: '1'
|
||||
});
|
||||
|
||||
/* socks */
|
||||
expect_fields('socks5', parse_uri('socks5://user:pass@g.example.com:1080#Socks', FEATURES, LOG), {
|
||||
label: 'Socks', type: 'socks', address: 'g.example.com', port: '1080',
|
||||
username: 'user', password: 'pass', socks_version: '5'
|
||||
});
|
||||
expect_fields('socks4', parse_uri('socks4://h.example.com:1080#Socks4', FEATURES, LOG), {
|
||||
socks_version: '4'
|
||||
});
|
||||
|
||||
/* shadowsocks: SIP002 with base64 user info */
|
||||
expect_fields('ss-sip002-b64', parse_uri('ss://YWVzLTI1Ni1nY206cGFzc3dvcmQ=@i.example.com:8388#SS-Node', FEATURES, LOG), {
|
||||
label: 'SS-Node', type: 'shadowsocks', address: 'i.example.com', port: '8388',
|
||||
shadowsocks_encrypt_method: 'aes-256-gcm', password: 'password'
|
||||
});
|
||||
|
||||
/* shadowsocks: SIP002 with plugin */
|
||||
expect_fields('ss-sip002-plugin', parse_uri('ss://YWVzLTI1Ni1nY206cGFzc3dvcmQ=@j.example.com:8388?plugin=simple-obfs%3Bobfs%3Dhttp#SS-Plugin', FEATURES, LOG), {
|
||||
label: 'SS-Plugin', type: 'shadowsocks', address: 'j.example.com', port: '8388',
|
||||
shadowsocks_plugin: 'obfs-local', shadowsocks_plugin_opts: 'obfs=http'
|
||||
});
|
||||
|
||||
/* shadowsocks: Shadowrocket whole-payload base64 */
|
||||
expect_fields('ss-shadowrocket', parse_uri('ss://YWVzLTI1Ni1nY206cGFzc3dvcmRAay5leGFtcGxlLmNvbTo4Mzg4#SS-SR', FEATURES, LOG), {
|
||||
label: 'SS-SR', type: 'shadowsocks', address: 'k.example.com', port: '8388',
|
||||
shadowsocks_encrypt_method: 'aes-256-gcm', password: 'password'
|
||||
});
|
||||
|
||||
/* trojan */
|
||||
expect_fields('trojan-ws', parse_uri('trojan://pass@l.example.com:443?type=ws&host=l.example.com&path=%2Fpath%3Fed%3D2048&sni=l.example.com#TrojanWs', FEATURES, LOG), {
|
||||
label: 'TrojanWs', type: 'trojan', address: 'l.example.com', port: '443', password: 'pass',
|
||||
transport: 'ws', ws_host: 'l.example.com', ws_path: '/path',
|
||||
websocket_early_data: '2048', websocket_early_data_header: 'Sec-WebSocket-Protocol',
|
||||
tls: '1', tls_sni: 'l.example.com'
|
||||
});
|
||||
expect_fields('trojan-grpc', parse_uri('trojan://pass@l.example.com:443?type=grpc&serviceName=gs#TrojanGrpc', FEATURES, LOG), {
|
||||
transport: 'grpc', grpc_servicename: 'gs'
|
||||
});
|
||||
|
||||
/* tuic */
|
||||
expect_fields('tuic', parse_uri('tuic://tuic-uuid:pass@m.example.com:443?congestion_control=bbr&udp_relay_mode=native&alpn=h3&sni=m.example.com#Tuic', FEATURES, LOG), {
|
||||
label: 'Tuic', type: 'tuic', address: 'm.example.com', port: '443',
|
||||
uuid: 'tuic-uuid', password: 'pass', tuic_congestion_control: 'bbr',
|
||||
tuic_udp_relay_mode: 'native', tls: '1', tls_sni: 'm.example.com', tls_alpn: [ 'h3' ]
|
||||
});
|
||||
|
||||
/* vless */
|
||||
expect_fields('vless-reality-grpc', parse_uri('vless://vless-uuid@n.example.com:443?security=reality&pbk=PUBKEY&sid=abcd&fp=chrome&type=grpc&serviceName=gs&sni=n.example.com&flow=xtls-rprx-vision#Vless', FEATURES, LOG), {
|
||||
label: 'Vless', type: 'vless', address: 'n.example.com', port: '443', uuid: 'vless-uuid',
|
||||
transport: 'grpc', grpc_servicename: 'gs', tls: '1', tls_sni: 'n.example.com',
|
||||
tls_reality: '1', tls_reality_public_key: 'PUBKEY', tls_reality_short_id: 'abcd',
|
||||
tls_utls: 'chrome', vless_flow: 'xtls-rprx-vision'
|
||||
});
|
||||
expect_fields('vless-ws-early-data', parse_uri('vless://vless-uuid@o.example.com:443?security=tls&type=ws&host=o.example.com&path=%2Fws%3Fed%3D1024&sni=o.example.com&alpn=h2%2Chttp%2F1.1#VlessWs', FEATURES, LOG), {
|
||||
type: 'vless', transport: 'ws', ws_host: 'o.example.com', ws_path: '/ws',
|
||||
websocket_early_data: '1024', websocket_early_data_header: 'Sec-WebSocket-Protocol',
|
||||
tls: '1', tls_alpn: [ 'h2', 'http/1.1' ]
|
||||
});
|
||||
|
||||
/* vmess */
|
||||
expect_fields('vmess-ws', parse_uri('vmess://eyJ2IjoiMiIsInBzIjoiVk1lc3MiLCJhZGQiOiJwLmV4YW1wbGUuY29tIiwicG9ydCI6IjQ0MyIsImlkIjoiM2FmODg1NjEtOWM2OS00YjE5LThmN2UtZjA4ZDU4MGJjMzM5IiwiYWlkIjoiMCIsIm5ldCI6IndzIiwidHlwZSI6Im5vbmUiLCJob3N0IjoicC5leGFtcGxlLmNvbSIsInBhdGgiOiIvd3MiLCJ0bHMiOiJ0bHMiLCJzbmkiOiJwLmV4YW1wbGUuY29tIn0=', FEATURES, LOG), {
|
||||
label: 'VMess', type: 'vmess', address: 'p.example.com', port: '443',
|
||||
uuid: '3af88561-9c69-4b19-8f7e-f08d580bc339', vmess_alterid: '0', vmess_encrypt: 'auto',
|
||||
vmess_global_padding: '1', transport: 'ws', ws_host: 'p.example.com', ws_path: '/ws',
|
||||
tls: '1', tls_sni: 'p.example.com'
|
||||
});
|
||||
|
||||
/* sip008 object */
|
||||
expect_fields('sip008', parse_uri({
|
||||
nodetype: 'sip008', remarks: 'SIP008', server: 'q.example.com', server_port: 8388,
|
||||
method: 'aes-256-gcm', password: 'pw'
|
||||
}, FEATURES, LOG), {
|
||||
label: 'SIP008', type: 'shadowsocks', address: 'q.example.com', port: 8388,
|
||||
shadowsocks_encrypt_method: 'aes-256-gcm', password: 'pw'
|
||||
});
|
||||
|
||||
/* rejection paths */
|
||||
expect_null('vless-kcp-unsupported', parse_uri('vless://u@r.example.com:443?type=kcp#Kcp', FEATURES, LOG));
|
||||
expect_null('hysteria-without-quic', parse_uri('hysteria://s.example.com:443?auth=a#NoQuic', NO_QUIC, LOG));
|
||||
expect_null('tuic-without-quic', parse_uri('tuic://u:p@s.example.com:443#NoQuic', NO_QUIC, LOG));
|
||||
expect_null('invalid-port', parse_uri('vless://u@t.example.com:99999?security=tls#BadPort', FEATURES, LOG));
|
||||
expect_null('unknown-scheme', parse_uri('unknown://u@t.example.com:443', FEATURES, LOG));
|
||||
|
||||
/* A malformed shadowsocks userinfo must be rejected, not raise.
|
||||
*
|
||||
* ss:// carries its method:password base64-encoded in the userinfo.
|
||||
* decodeBase64Str() returns null for anything that is not valid base64, and
|
||||
* ucode's split(null, ...) is null, so indexing [0] raised a ReferenceError.
|
||||
* parse_uri() does not catch it: the RPC import path happens to, but
|
||||
* update_subscriptions.uc does not, so ONE bad link aborted the whole update
|
||||
* and nothing was imported. A dot is not base64, so this is a realistic typo.
|
||||
* A link with no usable userinfo is simply not a node we can build. */
|
||||
expect_null('ss-nonbase64-userinfo', parse_uri('ss://a.b@1.2.3.4:8388#x', FEATURES, LOG));
|
||||
expect_null('ss-empty-userinfo', parse_uri('ss://@1.2.3.4:8388#x', FEATURES, LOG));
|
||||
|
||||
printf('%d checks, %d failures\n', checks, failures);
|
||||
exit(failures === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,217 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* PR-03 (Subscription Transaction Boundary): the round-trip
|
||||
* invariant for the canonical-Node pipeline. For every scheme the
|
||||
* parser accepts, parse_uri() -> normalize() -> flatten() must
|
||||
* produce a flat UCI dict that is field-for-field equal to what
|
||||
* the parser would have written directly. Repository then writes
|
||||
* that flat dict to UCI, so a saved canonical Node round-trips
|
||||
* back into an equivalent canonical Node when the Loader next
|
||||
* reads it.
|
||||
*
|
||||
* Two important properties this test pins:
|
||||
*
|
||||
* - canonical-to-flat mapping is consistent: parser and
|
||||
* flatten() agree on every UCI key (otherwise the
|
||||
* Repository's "delete what is no longer carried" walk
|
||||
* would silently drop fields the parser still emits);
|
||||
* - canonical -> flat -> canonical is identity for the data
|
||||
* the Loader reads (the Repository's actual contract).
|
||||
*
|
||||
* Run through tests/ucode/run.sh's parser tree staging (same as
|
||||
* test_parse_uri.uc): the staged work dir has the production
|
||||
* parser/ tree plus the homeproxy mock.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { parse_uri } from 'parser/uri.uc';
|
||||
import { normalize } from 'parser/normalize.uc';
|
||||
import { flatten } from 'parser/flatten.uc';
|
||||
|
||||
const FEATURES = { with_quic: true, with_utls: true };
|
||||
const LOG = (..._args) => {};
|
||||
|
||||
let failures = 0, checks = 0;
|
||||
|
||||
function expect(name, got, want) {
|
||||
checks++;
|
||||
if (sprintf('%J', got) !== sprintf('%J', want)) {
|
||||
printf('FAIL %s: got %J want %J\n', name, got, want);
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
|
||||
/* The invariant that matters for the Repository: every value the parser
|
||||
* actually set must survive parse -> normalize -> flatten unchanged.
|
||||
*
|
||||
* Nulls are excluded on purpose - removeBlankAttrs()/flatten() drop them
|
||||
* and the Loader reads an absent option as null, so a null round-trip is
|
||||
* observably lossless. A non-null value that disappears is data loss:
|
||||
* the Repository writes `flatten()`'s output, so on the next subscription
|
||||
* update that UCI option would be *deleted* from the stored node.
|
||||
*
|
||||
* The reverse direction is checked too: flatten() must not invent keys
|
||||
* the parser never produced, or the Repository would write options the
|
||||
* node form does not own. */
|
||||
function round_trip(name, uri, features) {
|
||||
const flat = parse_uri(uri, features, LOG);
|
||||
|
||||
/* A sample the parser rejects is a broken fixture, not a reason to skip.
|
||||
* Returning silently left that scheme's round trip unverified while the
|
||||
* suite stayed green. */
|
||||
if (!flat) {
|
||||
checks++;
|
||||
failures++;
|
||||
printf('FAIL %s: parse_uri() rejected a sample this test is meant to cover\n', name);
|
||||
return;
|
||||
}
|
||||
|
||||
const node = normalize(flat);
|
||||
const back = flatten(node);
|
||||
|
||||
/* 1. every non-null parser value survives, unchanged. */
|
||||
for (let k, v in flat) {
|
||||
if (v === null)
|
||||
continue;
|
||||
expect(sprintf('%s: %s survives round-trip', name, k), back[k], v);
|
||||
}
|
||||
|
||||
/* 2. flatten() only emits keys the parser produced (plus the
|
||||
* metadata the orchestrator attaches). */
|
||||
const flat_keys = keys(flat);
|
||||
for (let k, v in back) {
|
||||
if (v === null)
|
||||
continue;
|
||||
if (index(flat_keys, k) === -1)
|
||||
expect(sprintf('%s: %s is not a parser field', name, k), k, null);
|
||||
}
|
||||
|
||||
/* 3. and the canonical Node must carry the identity the Loader's
|
||||
* read path would reconstruct. */
|
||||
expect(sprintf('%s: canonical type', name), node.type, flat.type);
|
||||
expect(sprintf('%s: canonical address', name), node.address, flat.address);
|
||||
expect(sprintf('%s: canonical port', name), node.port, flat.port);
|
||||
expect(sprintf('%s: canonical name from label', name), node.name, flat.label);
|
||||
}
|
||||
|
||||
/* The same URI corpus test_parse_uri.uc asserts on, so any
|
||||
* regression in the mapping table will surface in BOTH tests.
|
||||
* Array destructuring is rejected by the target ucode, so each
|
||||
* sample is a 3-element object the loop indexes manually. */
|
||||
const SAMPLES = [
|
||||
{ name: 'anytls', uri: 'anytls://secret@a.example.com:443?sni=a.example.com#AnyTLS', features: FEATURES },
|
||||
{ name: 'https', uri: 'https://user:pass@b.example.com:8443#HTTPS', features: FEATURES },
|
||||
{ name: 'http', uri: 'http://c.example.com:8080#HTTP', features: FEATURES },
|
||||
{ name: 'hy1', uri: 'hysteria://d.example.com:36712?auth=pass&peer=d.example.com&protocol=udp#Hy1', features: FEATURES },
|
||||
{ name: 'hy2', uri: 'hysteria2://pass@e.example.com:443?sni=e.example.com&obfs=salamander&obfs-password=op#Hy2', features: FEATURES },
|
||||
{ name: 'hy2al', uri: 'hy2://pass@e.example.com:443#Hy2Alias', features: FEATURES },
|
||||
{ name: 'snell', uri: 'snell://f.example.com:443?psk=pskvalue&version=4&obfs=http&obfs-host=bing.com#Snell', features: FEATURES },
|
||||
{ name: 'socks5', uri: 'socks5://user:pass@g.example.com:1080#Socks', features: FEATURES },
|
||||
{ name: 'ss', uri: 'ss://YWVzLTI1Ni1nY206cGFzc3dvcmQ=@i.example.com:8388#SS', features: FEATURES },
|
||||
{ name: 'trojan', uri: 'trojan://pass@l.example.com:443?type=grpc&serviceName=gs&sni=l.example.com#Trojan', features: FEATURES },
|
||||
{ name: 'tuic', uri: 'tuic://tuic-uuid:pass@m.example.com:443?congestion_control=bbr&sni=m.example.com#Tuic', features: FEATURES },
|
||||
{ name: 'vless-r', uri: 'vless://vless-uuid@n.example.com:443?security=reality&pbk=PUBKEY&sid=abcd&type=grpc&serviceName=gs&sni=n.example.com#Vless', features: FEATURES },
|
||||
{ name: 'vmess', uri: 'vmess://eyJ2IjoiMiIsInBzIjoiVk1lc3MiLCJhZGQiOiJwLmV4YW1wbGUuY29tIiwicG9ydCI6IjQ0MyIsImlkIjoiM2FmODg1NjEtOWM2OS00YjE5LThmN2UtZjA4ZDU4MGJjMzM5IiwiYWlkIjoiMCIsIm5ldCI6IndzIiwidHlwZSI6Im5vbmUiLCJob3N0IjoicC5leGFtcGxlLmNvbSIsInBhdGgiOiIvd3MiLCJ0bHMiOiJ0bHMiLCJzbmkiOiJwLmV4YW1wbGUuY29tIn0=', features: FEATURES }
|
||||
];
|
||||
|
||||
/* ucode's for-in over an array yields the elements, not the indices. */
|
||||
for (let s in SAMPLES)
|
||||
round_trip(s.name, s.uri, s.features);
|
||||
|
||||
/* --- explicit canonical -> flat shape checks --------------------------- */
|
||||
|
||||
/* vless reality: many sub-objects must round-trip cleanly. */
|
||||
{
|
||||
const flat = parse_uri(
|
||||
'vless://vless-uuid@n.example.com:443?security=reality&pbk=PUBKEY&sid=abcd&type=grpc&serviceName=gs&sni=n.example.com#Vless',
|
||||
FEATURES, LOG
|
||||
);
|
||||
const node = normalize(flat);
|
||||
const back = flatten(node);
|
||||
expect('vless.reality: tls_reality_public_key',
|
||||
back.tls_reality_public_key, flat.tls_reality_public_key);
|
||||
expect('vless.reality: tls_reality_short_id',
|
||||
back.tls_reality_short_id, flat.tls_reality_short_id);
|
||||
expect('vless.reality: tls_sni',
|
||||
back.tls_sni, flat.tls_sni);
|
||||
expect('vless.reality: grpc_servicename',
|
||||
back.grpc_servicename, flat.grpc_servicename);
|
||||
expect('vless.reality: vless_flow',
|
||||
back.vless_flow, flat.vless_flow);
|
||||
}
|
||||
|
||||
/* shadowsocks plugin + udp_over_tcp must survive. */
|
||||
{
|
||||
const flat = parse_uri(
|
||||
'ss://YWVzLTI1Ni1nY206cGFzc3dvcmQ=@i.example.com:8388#SS',
|
||||
FEATURES, LOG
|
||||
);
|
||||
const node = normalize(flat);
|
||||
const back = flatten(node);
|
||||
expect('ss: shadowsocks_encrypt_method',
|
||||
back.shadowsocks_encrypt_method, flat.shadowsocks_encrypt_method);
|
||||
expect('ss: label survives canonical->flat',
|
||||
back.label, flat.label);
|
||||
}
|
||||
|
||||
/* --- grouphash metadata pass-through ---------------------------------- */
|
||||
|
||||
/* Repository attaches grouphash to the canonical Node after the
|
||||
* orchestrator builds it. flatten() must copy grouphash through
|
||||
* because the Repository's foreach walks `node_cache` keyed by
|
||||
* grouphash, not by what flatten emits. */
|
||||
{
|
||||
const flat = parse_uri(
|
||||
'vless://vless-uuid@n.example.com:443?security=reality&pbk=PUBKEY&sid=abcd&sni=n.example.com#Vless',
|
||||
FEATURES, LOG
|
||||
);
|
||||
const node = normalize(flat);
|
||||
node.grouphash = 'groupHash-XYZ';
|
||||
const back = flatten(node);
|
||||
expect('grouphash: passed through',
|
||||
back.grouphash, 'groupHash-XYZ');
|
||||
}
|
||||
|
||||
/* isExisting is the within-run marker Repository sets after a
|
||||
* successful write; it must NOT be emitted into UCI (else it
|
||||
* becomes a stale field on the next round). */
|
||||
{
|
||||
const flat = parse_uri(
|
||||
'vless://vless-uuid@n.example.com:443?sni=n.example.com#Vless',
|
||||
FEATURES, LOG
|
||||
);
|
||||
const node = normalize(flat);
|
||||
node.isExisting = true;
|
||||
const back = flatten(node);
|
||||
expect('isExisting: NOT in flat output',
|
||||
'isExisting' in back, false);
|
||||
}
|
||||
|
||||
/* --- null / empty canonical values are dropped ------------------------ */
|
||||
|
||||
/* Empty / null canonical values must not show up as `key ''`
|
||||
* entries in UCI - the Loader treats null and absent the same,
|
||||
* so the round-trip is still lossless. */
|
||||
{
|
||||
const node = {
|
||||
id: null,
|
||||
name: 'X',
|
||||
type: 'vless',
|
||||
address: 'a',
|
||||
port: '1',
|
||||
common: {},
|
||||
credentials: { uuid: 'u' },
|
||||
tls: { enabled: null, ech: {}, utls: {}, reality: {} },
|
||||
transport: { type: null },
|
||||
multiplex: { enabled: null, brutal: {} },
|
||||
protocol_options: { flow: '' }
|
||||
};
|
||||
const back = flatten(node);
|
||||
expect('flatten: empty string protocol_option is dropped',
|
||||
'flow' in back, false);
|
||||
}
|
||||
|
||||
printf('%d checks, %d failures\n', checks, failures);
|
||||
exit(failures === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,164 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* PR-02 (Parser Normalization): the parser writes a flat UCI-key
|
||||
* dict, normalize() turns it into the canonical Node shape the
|
||||
* Adapter reads. This test pins the contract so the mapping table
|
||||
* (parser/mapping.uc), the Loader's PROTOCOL_OPTIONS (now derived
|
||||
* from it) and the parser's normalize() cannot drift apart.
|
||||
*
|
||||
* Run through tests/ucode/run.sh's parser tree staging (same as
|
||||
* test_parse_uri.uc): the staged work dir has the production
|
||||
* parser/ tree plus the homeproxy mock.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { normalize } from 'parser/normalize.uc';
|
||||
|
||||
let checks = 0, failures = 0;
|
||||
function expect(name, got, want) {
|
||||
checks++;
|
||||
if (got !== want) {
|
||||
printf('FAIL %s: got %J want %J\n', name, got, want);
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
function expect_keys(name, got, want) {
|
||||
checks++;
|
||||
const keys_got = sort(keys(got));
|
||||
const keys_want = sort(want);
|
||||
const same = length(keys_got) === length(keys_want);
|
||||
if (!same || filter(keys_got, (k, i) => k !== keys_want[i]).length) {
|
||||
printf('FAIL %s: keys got %J want %J\n', name, keys_got, keys_want);
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- null in -> null out -------------------------------------------- */
|
||||
expect('normalize(null)', normalize(null), null);
|
||||
|
||||
/* --- shadowsocks: tls_sni + shadowsocks_* UCI keys -> canonical Node */
|
||||
const ss_flat = {
|
||||
label: 'ss1',
|
||||
type: 'shadowsocks',
|
||||
address: '1.2.3.4',
|
||||
port: '8388',
|
||||
password: 'pw',
|
||||
shadowsocks_encrypt_method: 'aes-256-gcm',
|
||||
shadowsocks_plugin: 'obfs-local',
|
||||
shadowsocks_plugin_opts: 'obfs=http;obfs-host=bing.com',
|
||||
udp_over_tcp: '1',
|
||||
udp_over_tcp_version: '2'
|
||||
};
|
||||
const ss_node = normalize(ss_flat);
|
||||
expect('ss.type', ss_node.type, 'shadowsocks');
|
||||
expect('ss.address', ss_node.address, '1.2.3.4');
|
||||
expect('ss.port', ss_node.port, '8388');
|
||||
expect('ss.credentials.password', ss_node.credentials.password, 'pw');
|
||||
expect('ss.credentials.method', ss_node.credentials.method, 'aes-256-gcm');
|
||||
expect('ss.protocol_options.plugin', ss_node.protocol_options.plugin, 'obfs-local');
|
||||
expect('ss.protocol_options.plugin_opts', ss_node.protocol_options.plugin_opts, 'obfs=http;obfs-host=bing.com');
|
||||
expect('ss.protocol_options.udp_over_tcp', ss_node.protocol_options.udp_over_tcp, '1');
|
||||
expect('ss.protocol_options.udp_over_tcp_version', ss_node.protocol_options.udp_over_tcp_version, '2');
|
||||
/* shadowsocks has no transport, multiplex, tls enabled: */
|
||||
expect('ss.transport.type', ss_node.transport.type, null);
|
||||
|
||||
/* --- vless: flow + packet_encoding -> canonical; tls shape filled */
|
||||
const vless_flat = {
|
||||
label: 'vl1',
|
||||
type: 'vless',
|
||||
address: '5.6.7.8',
|
||||
port: '443',
|
||||
uuid: 'uuid-x',
|
||||
transport: 'ws',
|
||||
tls: '1',
|
||||
tls_sni: 'vl.example.com',
|
||||
tls_alpn: ['h2', 'http/1.1'],
|
||||
tls_reality: '1',
|
||||
tls_reality_public_key: 'PUBKEY',
|
||||
tls_reality_short_id: 'abcd',
|
||||
tls_utls: 'chrome',
|
||||
vless_flow: 'xtls-rprx-vision',
|
||||
packet_encoding: 'xudp',
|
||||
ws_host: 'ws.example.com',
|
||||
ws_path: '/ws'
|
||||
};
|
||||
const vless_node = normalize(vless_flat);
|
||||
expect('vless.type', vless_node.type, 'vless');
|
||||
expect('vless.credentials.uuid', vless_node.credentials.uuid, 'uuid-x');
|
||||
expect('vless.protocol_options.flow', vless_node.protocol_options.flow, 'xtls-rprx-vision');
|
||||
expect('vless.protocol_options.packet_encoding', vless_node.protocol_options.packet_encoding, 'xudp');
|
||||
expect('vless.tls.enabled', vless_node.tls.enabled, '1');
|
||||
expect('vless.tls.server_name', vless_node.tls.server_name, 'vl.example.com');
|
||||
expect('vless.tls.reality.enabled', vless_node.tls.reality.enabled, '1');
|
||||
expect('vless.tls.reality.public_key', vless_node.tls.reality.public_key, 'PUBKEY');
|
||||
expect('vless.tls.utls.fingerprint', vless_node.tls.utls.fingerprint, 'chrome');
|
||||
expect('vless.transport.type', vless_node.transport.type, 'ws');
|
||||
expect('vless.transport.path', vless_node.transport.path, '/ws');
|
||||
/* A websocket transport keeps its host in `headers.Host`, not `host`
|
||||
* (only the http / httpupgrade transports use `host`), matching the
|
||||
* Loader's load_transport() shape and buildTransportObject()'s
|
||||
* expectation. */
|
||||
expect('vless.transport.host', vless_node.transport.host, null);
|
||||
expect('vless.transport.headers', vless_node.transport.headers?.Host, 'ws.example.com');
|
||||
|
||||
/* --- hysteria2: hy_* UCI -> protocol_options.hop_interval / obfs_type */
|
||||
const hy2_flat = {
|
||||
label: 'hy2',
|
||||
type: 'hysteria2',
|
||||
address: 'a.b.c',
|
||||
port: '443',
|
||||
password: 'pw',
|
||||
hysteria_obfs_type: 'salamander',
|
||||
hysteria_obfs_password: 'obfspw',
|
||||
hysteria_up_mbps: '100',
|
||||
hysteria_hop_interval: '30',
|
||||
tls: '1',
|
||||
tls_sni: 'hy2.example.com'
|
||||
};
|
||||
const hy2_node = normalize(hy2_flat);
|
||||
expect('hy2.protocol_options.obfs_type', hy2_node.protocol_options.obfs_type, 'salamander');
|
||||
expect('hy2.protocol_options.obfs_password', hy2_node.protocol_options.obfs_password, 'obfspw');
|
||||
expect('hy2.protocol_options.up_mbps', hy2_node.protocol_options.up_mbps, '100');
|
||||
expect('hy2.protocol_options.hop_interval', hy2_node.protocol_options.hop_interval, '30');
|
||||
/* protocol_options must NOT carry over fields that are not in PROTOCOL_TO_UCI;
|
||||
* the parser may write extra keys (e.g. from the form) that should be
|
||||
* dropped at the canonical boundary. */
|
||||
expect_keys('hy2.protocol_options keys',
|
||||
hy2_node.protocol_options,
|
||||
['obfs_type', 'obfs_password', 'up_mbps', 'hop_interval']);
|
||||
|
||||
/* --- direct: protocol_options carries override_address/port */
|
||||
const direct_flat = {
|
||||
label: 'direct1',
|
||||
type: 'direct',
|
||||
address: '10.0.0.1',
|
||||
port: '80',
|
||||
override_address: '1.0.0.1',
|
||||
override_port: '443'
|
||||
};
|
||||
const direct_node = normalize(direct_flat);
|
||||
expect('direct.protocol_options.override_address', direct_node.protocol_options.override_address, '1.0.0.1');
|
||||
expect('direct.protocol_options.override_port', direct_node.protocol_options.override_port, '443');
|
||||
/* direct has no credentials: */
|
||||
expect_keys('direct.credentials keys', direct_node.credentials, []);
|
||||
|
||||
/* --- empty/null UCI values are dropped from protocol_options (the
|
||||
* Adapter's removeBlankAttrs would drop them again at emit time, but
|
||||
* keeping normalize strict means a stale `flow: ''` from a recent
|
||||
* share link does not appear in REQUIRED_CREDENTIALS or similar
|
||||
* checks that walk the canonical shape). */
|
||||
const empty_flat = {
|
||||
type: 'vless',
|
||||
address: 'a',
|
||||
port: '1',
|
||||
uuid: 'u',
|
||||
vless_flow: '',
|
||||
packet_encoding: null
|
||||
};
|
||||
const empty_node = normalize(empty_flat);
|
||||
expect_keys('empty.protocol_options keys', empty_node.protocol_options, []);
|
||||
|
||||
printf('%d checks, %d failures\n', checks, failures);
|
||||
exit(failures === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,225 @@
|
||||
#!/bin/sh
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# Protocol inventory invariant.
|
||||
#
|
||||
# The protocol surface is spread over several places that must agree. PR-02
|
||||
# moved two of them, so the paths here are the current ones:
|
||||
#
|
||||
# parser/uri.uc scheme -> type (what a share link becomes)
|
||||
# model.uc CREDENTIALS (which UCI option holds each credential)
|
||||
# parser/mapping.uc PROTOCOL_TO_UCI (which UCI option holds each protocol option;
|
||||
# the Loader imports it as PROTOCOL_OPTIONS)
|
||||
# adapter.uc REQUIRED_CREDENTIALS (what a buildable node needs)
|
||||
# adapter.uc OPTION_FIELDS (what becomes a sing-box field)
|
||||
# snapshots/generator/outbounds.json (what is actually emitted)
|
||||
#
|
||||
# The frontend has a matching check of its own,
|
||||
# tests/frontend-protocol-inventory.js: the browser's protocol list must be a
|
||||
# subset of what the backend models, which is the direction this shape test
|
||||
# cannot see.
|
||||
#
|
||||
# Adding a protocol to one of them and forgetting another is the failure mode
|
||||
# this test exists for: WireGuard shipped with no loader row (so the endpoint
|
||||
# builder read fields that did not exist) and SSH shipped with no loader row
|
||||
# and the wrong credential option name. Neither failed any test.
|
||||
#
|
||||
# Usage: sh tests/ucode/test_protocol_inventory.sh <repo-root> [work-dir]
|
||||
|
||||
ROOT="${1:-.}"
|
||||
WORK="${2:-/tmp/hp-inventory-test}"
|
||||
|
||||
ROOT="$(cd "$ROOT" && pwd)"
|
||||
SCRIPTS="$ROOT/root/etc/homeproxy/scripts"
|
||||
SNAPSHOT="$ROOT/tests/snapshots/generator/outbounds.json"
|
||||
FAILED=0
|
||||
|
||||
rm -rf "$WORK"
|
||||
mkdir -p "$WORK/parser" "$WORK/inventory/config"
|
||||
|
||||
# --- stage 1: what does the parser produce? ------------------------------
|
||||
# PR-02 moved the parsers to scripts/parser/{uri,protocols,validator,
|
||||
# normalize,mapping}.uc; stage the whole tree plus the homeproxy mock
|
||||
# so the share-link importer can find every import it needs.
|
||||
cp "$ROOT/tests/ucode/mocks/homeproxy.uc" "$WORK/parser/homeproxy.uc"
|
||||
# The test module imports 'parser/uri.uc', which resolves relative to
|
||||
# the -L search root, so the parser tree must be staged as a `parser/`
|
||||
# subdirectory of that root (not flattened into it).
|
||||
mkdir -p "$WORK/parser/parser"
|
||||
cp "$SCRIPTS/parser/"*.uc "$WORK/parser/parser/"
|
||||
# The Loader imports PROTOCOL_OPTIONS from '../parser/mapping.uc'.
|
||||
# After staging, the loader lives at $WORK/inventory/config/loader.uc,
|
||||
# so the parser tree must also live at $WORK/inventory/parser/ for the
|
||||
# import to resolve. Mirror it once now so inventory.uc below can use
|
||||
# the loader as-is.
|
||||
mkdir -p "$WORK/inventory/parser"
|
||||
cp "$SCRIPTS/parser/"*.uc "$WORK/inventory/parser/"
|
||||
|
||||
cat > "$WORK/parser/types.uc" <<'EOF'
|
||||
'use strict';
|
||||
|
||||
import { parse_uri } from 'parser/uri.uc';
|
||||
|
||||
const FEATURES = { with_quic: true, with_utls: true };
|
||||
const LOG = function() {};
|
||||
|
||||
/* One sample per scheme the parser accepts. The values are the same ones
|
||||
* test_parse_uri.uc asserts on, so this list stays honest. */
|
||||
const SAMPLES = [
|
||||
'anytls://secret@a.example.com:443?sni=a.example.com#AnyTLS',
|
||||
'https://user:pass@b.example.com:8443#HTTPS',
|
||||
'http://c.example.com:8080#HTTP',
|
||||
'hysteria://d.example.com:36712?auth=pass&peer=d.example.com&protocol=udp#Hy1',
|
||||
'hysteria2://pass@e.example.com:443?sni=e.example.com&obfs=salamander&obfs-password=op#Hy2',
|
||||
'hy2://pass@e.example.com:443#Hy2Alias',
|
||||
'snell://f.example.com:443?psk=pskvalue&version=4&obfs=http&obfs-host=bing.com#Snell',
|
||||
'socks5://user:pass@g.example.com:1080#Socks',
|
||||
'ss://YWVzLTI1Ni1nY206cGFzc3dvcmQ=@i.example.com:8388#SS',
|
||||
'trojan://pass@l.example.com:443?type=grpc&serviceName=gs&sni=l.example.com#Trojan',
|
||||
'tuic://tuic-uuid:pass@m.example.com:443?congestion_control=bbr&sni=m.example.com#Tuic',
|
||||
'vless://vless-uuid@n.example.com:443?security=reality&pbk=PUBKEY&sid=abcd&type=grpc&serviceName=gs&sni=n.example.com#Vless',
|
||||
'vmess://eyJ2IjoiMiIsInBzIjoiVk1lc3MiLCJhZGQiOiJwLmV4YW1wbGUuY29tIiwicG9ydCI6IjQ0MyIsImlkIjoiM2FmODg1NjEtOWM2OS00YjE5LThmN2UtZjA4ZDU4MGJjMzM5IiwiYWlkIjoiMCIsIm5ldCI6IndzIiwidHlwZSI6Im5vbmUiLCJob3N0IjoicC5leGFtcGxlLmNvbSIsInBhdGgiOiIvd3MiLCJ0bHMiOiJ0bHMiLCJzbmkiOiJwLmV4YW1wbGUuY29tIn0='
|
||||
];
|
||||
|
||||
let types = [];
|
||||
for (let uri in SAMPLES) {
|
||||
const config = parse_uri(uri, FEATURES, LOG);
|
||||
if (config)
|
||||
push(types, config.type);
|
||||
}
|
||||
|
||||
printf('%.J\n', types);
|
||||
EOF
|
||||
|
||||
if ! ( cd "$WORK/parser" && ucode -L "$WORK/parser" types.uc > "$WORK/parser/types.json" 2> "$WORK/parser/types.err" ); then
|
||||
echo "FAIL: could not run the share-link parsers"
|
||||
head -8 "$WORK/parser/types.err"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- stage 2: do the tables and the golden snapshot agree? ---------------
|
||||
VALIDATE_DATA="${HP_VALIDATE_DATA:-/sbin/validate_data}"
|
||||
sed -e "s#/sbin/validate_data#${VALIDATE_DATA}#" \
|
||||
"$SCRIPTS/homeproxy.uc" > "$WORK/inventory/homeproxy.uc"
|
||||
cp "$SCRIPTS/config/loader.uc" "$WORK/inventory/config/"
|
||||
cp "$SCRIPTS/config/model.uc" "$WORK/inventory/config/"
|
||||
cp "$SCRIPTS/config/adapter.uc" "$WORK/inventory/config/"
|
||||
cp "$WORK/parser/types.json" "$WORK/inventory/parser-types.json"
|
||||
cp "$SNAPSHOT" "$WORK/inventory/outbounds.json"
|
||||
|
||||
cat > "$WORK/inventory/inventory.uc" <<'EOF'
|
||||
'use strict';
|
||||
|
||||
import { readfile } from 'fs';
|
||||
|
||||
import {
|
||||
CREDENTIALS, INBOUND_CREDENTIALS, INBOUND_OPTIONS
|
||||
} from './config/model.uc';
|
||||
/* PR-02 moved the client option table to parser/mapping.uc; the Loader
|
||||
* imports it from there as PROTOCOL_OPTIONS. */
|
||||
import { PROTOCOL_TO_UCI as PROTOCOL_OPTIONS } from './parser/mapping.uc';
|
||||
import { OPTION_FIELDS, REQUIRED_CREDENTIALS } from './config/adapter.uc';
|
||||
|
||||
const parser_types = json(readfile('parser-types.json')) || [];
|
||||
const golden = json(readfile('outbounds.json')) || {};
|
||||
|
||||
/* Types the adapter turns into a sing-box *outbound*. */
|
||||
const emitted_types = [];
|
||||
for (let id in keys(golden))
|
||||
push(emitted_types, golden[id].type);
|
||||
|
||||
/* Protocols that are modelled but deliberately produce something other than
|
||||
* an outbound: WireGuard becomes an endpoint (generate_endpoint()), and is
|
||||
* covered by tests/fixtures/generators/wireguard.uci instead of the golden
|
||||
* snapshot. */
|
||||
const ENDPOINT_TYPES = ['wireguard'];
|
||||
|
||||
let failures = 0, checks = 0;
|
||||
|
||||
function check(what, ok, detail) {
|
||||
checks++;
|
||||
if (ok)
|
||||
return;
|
||||
printf('FAIL %s%s\n', what, detail ? ': ' + detail : '');
|
||||
failures++;
|
||||
}
|
||||
|
||||
function contains(list, value) {
|
||||
return index(list, value) !== -1;
|
||||
}
|
||||
|
||||
/* 1. Every protocol any layer names must exist in the loader's option table;
|
||||
* that table is what makes the protocol reachable from UCI at all. */
|
||||
const LAYERS = {
|
||||
'OPTION_FIELDS': keys(OPTION_FIELDS),
|
||||
'REQUIRED_CREDENTIALS': keys(REQUIRED_CREDENTIALS),
|
||||
'CREDENTIALS': keys(CREDENTIALS),
|
||||
'ENDPOINT_TYPES': ENDPOINT_TYPES
|
||||
};
|
||||
|
||||
for (let layer in keys(LAYERS)) {
|
||||
for (let name in LAYERS[layer]) {
|
||||
check(sprintf('%s.%s is modelled by loader PROTOCOL_OPTIONS', layer, name),
|
||||
name in PROTOCOL_OPTIONS);
|
||||
}
|
||||
}
|
||||
|
||||
/* 2. Every type the parser can produce must be fully modelled: the loader
|
||||
* must know its options and the adapter must be able to emit it. */
|
||||
/* Non-empty guard: with an empty parser-types.json every check below would
|
||||
* vanish and the section would "pass" by having nothing to check - which is
|
||||
* how a parser that stopped producing types could go unnoticed. */
|
||||
check('the parser produced at least one type to cross-check', length(parser_types) > 0);
|
||||
|
||||
for (let type_name in parser_types) {
|
||||
check(sprintf("parser type '%s' is in PROTOCOL_OPTIONS", type_name),
|
||||
type_name in PROTOCOL_OPTIONS);
|
||||
check(sprintf("parser type '%s' is in OPTION_FIELDS", type_name),
|
||||
type_name in OPTION_FIELDS);
|
||||
check(sprintf("parser type '%s' has a golden outbound", type_name),
|
||||
contains(emitted_types, type_name));
|
||||
}
|
||||
|
||||
/* 3. Every protocol the adapter can emit, and every protocol it requires
|
||||
* credentials for, must be pinned by the golden snapshot - otherwise a
|
||||
* field regression in that protocol is invisible. */
|
||||
for (let type_name in keys(OPTION_FIELDS)) {
|
||||
check(sprintf("OPTION_FIELDS type '%s' has a golden outbound", type_name),
|
||||
contains(emitted_types, type_name));
|
||||
}
|
||||
for (let type_name in keys(REQUIRED_CREDENTIALS)) {
|
||||
if (contains(ENDPOINT_TYPES, type_name))
|
||||
continue;
|
||||
check(sprintf("REQUIRED_CREDENTIALS type '%s' has a golden outbound", type_name),
|
||||
contains(emitted_types, type_name));
|
||||
}
|
||||
|
||||
/* 4. The server side of the surface must be modelled too. Every protocol
|
||||
* with a per-protocol option row must also have a credential row, or
|
||||
* the Loader reads options for a protocol whose credentials it never
|
||||
* loads. (The reverse does not hold: trojan / shadowtls / http /
|
||||
* mixed / naive / socks have credentials and no per-protocol
|
||||
* options.) */
|
||||
for (let name in keys(INBOUND_OPTIONS)) {
|
||||
check(sprintf("INBOUND_OPTIONS.%s has an INBOUND_CREDENTIALS row", name),
|
||||
name in INBOUND_CREDENTIALS);
|
||||
}
|
||||
|
||||
/* 5. The golden snapshot must not carry a protocol nobody models. */
|
||||
for (let type_name in emitted_types) {
|
||||
check(sprintf("golden type '%s' is in PROTOCOL_OPTIONS", type_name),
|
||||
type_name in PROTOCOL_OPTIONS);
|
||||
}
|
||||
|
||||
printf('%d checks, %d failures\n', checks, failures);
|
||||
exit(failures ? 1 : 0);
|
||||
EOF
|
||||
|
||||
if ( cd "$WORK/inventory" && ucode -L "$WORK/inventory" inventory.uc ); then
|
||||
echo "PASS: protocol inventory is consistent"
|
||||
else
|
||||
echo "FAIL: protocol inventory is inconsistent"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
exit $FAILED
|
||||
@@ -0,0 +1,107 @@
|
||||
#!/bin/sh
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# resource_blob_sha.uc: the git blob id helper the resource updater verifies
|
||||
# downloads with.
|
||||
#
|
||||
# The check it feeds is only as good as this digest, so the expected values
|
||||
# here are git's own: `git hash-object` on the same bytes, which is the oracle
|
||||
# the helper has to reproduce (sha1("blob <len>\0" + content)). The fixtures
|
||||
# are chosen to cover the two things an implementation gets wrong quietly:
|
||||
# the empty file (a real blob id, not "no digest") and content whose byte
|
||||
# length differs from its character count.
|
||||
#
|
||||
# Usage: sh tests/ucode/test_resource_blob_sha.sh <repo-root> [workdir]
|
||||
|
||||
set -u
|
||||
|
||||
ROOT="$(cd "${1:-$(dirname "$0")/../..}" && pwd)"
|
||||
WORK="${2:-/tmp/hp-blob-sha-test}"
|
||||
|
||||
if ! command -v ucode > "/dev/null" 2>&1; then
|
||||
echo "NOT RUN: ucode is not on PATH."
|
||||
exit 2
|
||||
fi
|
||||
|
||||
HELPER="$ROOT/root/etc/homeproxy/scripts/resource_blob_sha.uc"
|
||||
rm -rf "$WORK"
|
||||
mkdir -p "$WORK"
|
||||
|
||||
FAILED=0
|
||||
CHECKS=0
|
||||
FAILURES=0
|
||||
|
||||
expect() {
|
||||
# expect <name> <actual> <expected>
|
||||
CHECKS=$((CHECKS + 1))
|
||||
if [ "$2" = "$3" ]; then
|
||||
echo "PASS: $1"
|
||||
else
|
||||
echo "FAIL: $1 (expected '$3', got '$2')"
|
||||
FAILED=1
|
||||
FAILURES=$((FAILURES + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
blob_sha() {
|
||||
ucode -S "$HELPER" "$1" 2>"/dev/null"
|
||||
}
|
||||
|
||||
# --- fixtures ---------------------------------------------------------------
|
||||
: > "$WORK/empty"
|
||||
printf 'abc' > "$WORK/abc"
|
||||
printf '中文abc\n' > "$WORK/utf8"
|
||||
printf 'line one\nline two\n' > "$WORK/two-lines"
|
||||
|
||||
# Expected values are git's own, printed at the end of this script so they can
|
||||
# be re-derived with `git hash-object <file>` after any change to the helper's
|
||||
# contract. They are literals rather than a live `git hash-object` call because
|
||||
# the target this runs on has no git - and a test that silently loses its
|
||||
# oracle on the device is worse than a literal with this comment on it.
|
||||
expect "empty file" "$(blob_sha "$WORK/empty")" "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"
|
||||
expect "abc" "$(blob_sha "$WORK/abc")" "f2ba8f84ab5c1bce84a7b441cb1959cfc7093b7f"
|
||||
expect "two lines" "$(blob_sha "$WORK/two-lines")" "e5c5c5583f49a34e86ce622b59363df99e09d4c6"
|
||||
expect "multibyte content" "$(blob_sha "$WORK/utf8")" "eadc1642bc94f8074dc8e3b1be0e3f0885d3f912"
|
||||
|
||||
# The helper must not invent a digest for something it cannot read: the
|
||||
# updater treats an empty answer as "cannot verify" and refuses, so a
|
||||
# non-empty answer here would turn an unreadable file into a passing check.
|
||||
expect "missing file produces no digest" "$(blob_sha "$WORK/does-not-exist")" ""
|
||||
ucode -S "$HELPER" >/dev/null 2>&1
|
||||
expect "no argument exits non-zero" "$?" "1"
|
||||
ucode -S "$HELPER" "$WORK/does-not-exist" >/dev/null 2>&1
|
||||
expect "unreadable file exits non-zero" "$?" "1"
|
||||
|
||||
# Two different contents must not collide (the trivial "always returns a
|
||||
# constant" regression).
|
||||
if [ -n "$(blob_sha "$WORK/abc")" ] && [ "$(blob_sha "$WORK/abc")" != "$(blob_sha "$WORK/two-lines")" ]; then
|
||||
CHECKS=$((CHECKS + 1))
|
||||
echo "PASS: different contents produce different digests"
|
||||
else
|
||||
CHECKS=$((CHECKS + 1))
|
||||
FAILED=1
|
||||
FAILURES=$((FAILURES + 1))
|
||||
echo "FAIL: different contents produced the same digest"
|
||||
fi
|
||||
|
||||
# The repository's own resource files are the real inputs: verify them against
|
||||
# git when the host has it (the CI runner and a developer machine do; a router
|
||||
# does not). This is the check that would notice the helper drifting from git's
|
||||
# format on a file it actually has to hash.
|
||||
if command -v git > "/dev/null" 2>&1 && [ -d "$ROOT/.git" ]; then
|
||||
for f in "root/etc/homeproxy/resources/china_ip4.txt" \
|
||||
"root/etc/homeproxy/resources/gfw_list.txt"; do
|
||||
[ -f "$ROOT/$f" ] || continue
|
||||
expect "matches git hash-object: $(basename "$f")" \
|
||||
"$(blob_sha "$ROOT/$f")" "$(git hash-object "$ROOT/$f")"
|
||||
done
|
||||
fi
|
||||
|
||||
printf '%d checks, %d failures\n' "$CHECKS" "$FAILURES"
|
||||
if [ "$FAILED" != "0" ]; then
|
||||
echo "BLOB SHA TESTS FAILED"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "BLOB SHA TESTS PASSED"
|
||||
exit 0
|
||||
+272
@@ -0,0 +1,272 @@
|
||||
#!/bin/sh
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# Behaviour tests for the rpcd module (root/usr/share/rpcd/ucode/luci.homeproxy).
|
||||
#
|
||||
# Until now that file was only ever compiled for syntax. tests/ucode/run.sh
|
||||
# rewrites its absolute imports and runs `ucode -c`, and nothing called a single
|
||||
# one of its ten methods - which is why the "Upload ECH config" button could
|
||||
# stay 100% broken: the frontend has called
|
||||
# certificate_write('client_ech_conf') since the initial commit, the ACL
|
||||
# granted the write, and the backend simply had no case for that name.
|
||||
#
|
||||
# This drives the methods directly with a fake request, the same way
|
||||
# tests/frontend-validators.js drives the frontend's validate callbacks.
|
||||
#
|
||||
# Usage: sh tests/ucode/test_rpc_methods.sh <repo-root> [workdir]
|
||||
|
||||
set -u
|
||||
|
||||
ROOT="$(cd "${1:-$(dirname "$0")/../..}" && pwd)"
|
||||
WORK="${2:-/tmp/hp-rpc-methods}"
|
||||
|
||||
if ! command -v ucode > "/dev/null" 2>&1; then
|
||||
echo "NOT RUN: ucode is not on PATH."
|
||||
exit 2
|
||||
fi
|
||||
|
||||
rm -rf "$WORK"
|
||||
mkdir -p "$WORK/scripts"
|
||||
cp -R "$ROOT/root/etc/homeproxy/scripts/." "$WORK/scripts/"
|
||||
|
||||
# stage_rewrite <src> <dst>: point a file's absolute /etc/homeproxy paths at the
|
||||
# sandbox, and nothing else. Both the imports and the runtime directories have
|
||||
# to move, or the test would write real certificates into /etc/homeproxy/certs.
|
||||
# The certificate staging prefix is moved too (it is a literal /tmp path inside
|
||||
# luci.homeproxy): the driver then writes and checks the staged upload under
|
||||
# $WORK/tmp, so the test never touches a fixed global path and two concurrent
|
||||
# runs cannot delete each other's upload between the stage and the call.
|
||||
stage_rewrite() {
|
||||
sed -e "s#/etc/homeproxy/scripts/#$WORK/scripts/#g" \
|
||||
-e "s#^const HP_DIR = '/etc/homeproxy';#const HP_DIR = '$WORK';#" \
|
||||
-e "s#^export const HP_DIR = '/etc/homeproxy';#export const HP_DIR = '$WORK';#" \
|
||||
-e "s#^const RUN_DIR = '/var/run/homeproxy';#const RUN_DIR = '$WORK/run';#" \
|
||||
-e "s#^export const RUN_DIR = '/var/run/homeproxy';#export const RUN_DIR = '$WORK/run';#" \
|
||||
-e "s#^export const UCICONFIG_DIR = '/etc/config';#export const UCICONFIG_DIR = '$WORK/cfg';#" \
|
||||
-e "s#/tmp/homeproxy_cert_#$WORK/tmp/homeproxy_cert_#g" \
|
||||
"$1" > "$2"
|
||||
}
|
||||
|
||||
stage_rewrite "$WORK/scripts/homeproxy.uc" "$WORK/scripts/homeproxy.uc.new"
|
||||
mv -f "$WORK/scripts/homeproxy.uc.new" "$WORK/scripts/homeproxy.uc"
|
||||
stage_rewrite "$ROOT/root/usr/share/rpcd/ucode/luci.homeproxy" "$WORK/rpc.uc"
|
||||
|
||||
# update_subscriptions.call() execs the staged updater through /bin/sh, so its
|
||||
# shebang has to name the ucode that is running this test. On a target that is
|
||||
# /usr/bin/ucode and nothing needs rewriting; on the off-target testbed the
|
||||
# toolchain is built into a private prefix (tests/toolchain/build-ucode-*.sh)
|
||||
# by design, and CI has nothing at /usr/bin/ucode at all - the first version of
|
||||
# this test came back as "not found" (exit 127) there and passed on the device,
|
||||
# which is the kind of environment dependence the staging is supposed to
|
||||
# remove. The rewrite is asserted, not assumed.
|
||||
UCODE_BIN="$(command -v ucode)"
|
||||
if [ "$UCODE_BIN" != "/usr/bin/ucode" ]; then
|
||||
# `|` as the delimiter, not `#`: the pattern and the replacement both
|
||||
# contain `#!`, and busybox sed rejects the escaped form ("bad option in
|
||||
# substitution expression") while GNU sed accepts it.
|
||||
sed -e "1s|^#!/usr/bin/ucode\$|#!$UCODE_BIN|" \
|
||||
"$WORK/scripts/update_subscriptions.uc" > "$WORK/scripts/update_subscriptions.uc.new"
|
||||
mv -f "$WORK/scripts/update_subscriptions.uc.new" "$WORK/scripts/update_subscriptions.uc"
|
||||
if ! head -n 1 "$WORK/scripts/update_subscriptions.uc" | grep -qxF "#!$UCODE_BIN"; then
|
||||
echo "FAIL: could not point the staged updater's shebang at $UCODE_BIN"
|
||||
head -n 1 "$WORK/scripts/update_subscriptions.uc"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
# After the rewrite, not before: the sed above creates the new file with the
|
||||
# default umask, so a chmod applied to the original would be lost with the mv
|
||||
# and /bin/sh would refuse to exec it (exit 126).
|
||||
chmod +x "$WORK/scripts/update_subscriptions.uc"
|
||||
|
||||
mkdir -p "$WORK/certs" "$WORK/run" "$WORK/cfg" "$WORK/tmp"
|
||||
|
||||
cat > "$WORK/driver_body.uc" <<'DRIVER'
|
||||
/* Appended to a copy of the module, so this runs in the module's own scope and
|
||||
* drives its `methods` table exactly as rpcd would hand it to a request. */
|
||||
/* writefile/readfile/access come from the module's own `fs` import above;
|
||||
* only what is not already in scope is imported here. */
|
||||
const WORK = '@@WORK@@';
|
||||
const rpc = methods;
|
||||
|
||||
let checks = 0, failures = 0;
|
||||
function check(what, ok, detail) {
|
||||
checks++;
|
||||
if (ok) return;
|
||||
failures++;
|
||||
printf('FAIL %s%s\n', what, detail != null ? ': ' + detail : '');
|
||||
}
|
||||
|
||||
const CERT = '-----BEGIN CERTIFICATE-----\n' +
|
||||
'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n' +
|
||||
'-----END CERTIFICATE-----\n';
|
||||
const KEY = '-----BEGIN RSA PRIVATE KEY-----\n' +
|
||||
'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n' +
|
||||
'-----END RSA PRIVATE KEY-----\n';
|
||||
const ECH = '-----BEGIN ECH CONFIGS-----\n' +
|
||||
'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n' +
|
||||
'-----END ECH CONFIGS-----\n';
|
||||
|
||||
/* The upload path is what the frontend's ui.uploadFile writes and what the
|
||||
* module reads. The staged module's /tmp/homeproxy_cert_ prefix is rewritten
|
||||
* to this run's work dir, so stage() and the module agree without either
|
||||
* touching a fixed /tmp path. */
|
||||
const STAGE_DIR = '@@STAGE@@';
|
||||
function stage(filename, content) {
|
||||
writefile(sprintf('%s/homeproxy_cert_%s.tmp', STAGE_DIR, filename), content);
|
||||
}
|
||||
|
||||
function tmp_exists(filename) {
|
||||
return access(sprintf('%s/homeproxy_cert_%s.tmp', STAGE_DIR, filename));
|
||||
}
|
||||
|
||||
function call(filename) {
|
||||
return rpc.certificate_write.call({ args: { filename: filename } });
|
||||
}
|
||||
|
||||
/* --- the four names the frontend actually sends ------------------------- */
|
||||
|
||||
const names = ['client_ca', 'server_publickey', 'server_privatekey', 'client_ech_conf'];
|
||||
const bodies = [CERT, CERT, KEY, ECH];
|
||||
|
||||
for (let i = 0; i < length(names); i++) {
|
||||
const name = names[i];
|
||||
stage(name, bodies[i]);
|
||||
const ret = call(name);
|
||||
|
||||
check(sprintf('%s: accepted', name), ret && ret.result === true,
|
||||
ret && (ret.error || sprintf('result=%J', ret.result)));
|
||||
|
||||
if (ret && ret.result === true) {
|
||||
const stored = readfile(sprintf('%s/certs/%s.pem', WORK, name));
|
||||
check(sprintf('%s: stored with a trailing newline', name),
|
||||
stored != null && match(stored, /\n$/));
|
||||
check(sprintf('%s: staging file removed after success', name), !tmp_exists(name));
|
||||
}
|
||||
}
|
||||
|
||||
/* --- the type checks still hold ----------------------------------------- */
|
||||
|
||||
stage('client_ca', KEY);
|
||||
check('client_ca rejects a private key', call('client_ca').result === false);
|
||||
|
||||
stage('server_privatekey', CERT);
|
||||
check('server_privatekey rejects a certificate', call('server_privatekey').result === false);
|
||||
|
||||
/* An ECH config is not a certificate: it has its own PEM markers, so the
|
||||
* certificate validator must not be reused for it. */
|
||||
stage('client_ech_conf', CERT);
|
||||
check('client_ech_conf rejects a certificate body', call('client_ech_conf').result === false);
|
||||
|
||||
stage('client_ech_conf', 'not a pem at all');
|
||||
check('client_ech_conf rejects garbage', call('client_ech_conf').result === false);
|
||||
|
||||
/* --- unknown names and empty uploads ------------------------------------ */
|
||||
|
||||
stage('client_ca', CERT);
|
||||
const bogus = call('not_a_certificate');
|
||||
check('an unknown filename is refused', bogus.result === false);
|
||||
check('an unknown filename says why', bogus.error === 'illegal cerificate filename',
|
||||
bogus.error);
|
||||
|
||||
writefile(sprintf('%s/homeproxy_cert_client_ca.tmp', STAGE_DIR), '');
|
||||
check('an empty upload is refused', call('client_ca').error === 'empty certificate file');
|
||||
|
||||
system(sprintf('rm -f %s/homeproxy_cert_client_ca.tmp', STAGE_DIR));
|
||||
check('a missing upload is refused', call('client_ca').result === false);
|
||||
|
||||
/* No method may throw on a request with no arguments at all. */
|
||||
let threw = null;
|
||||
try { rpc.certificate_write.call({}); } catch (e) { threw = sprintf('%s: %s', e.type, e.message); }
|
||||
check('certificate_write tolerates an empty request', threw == null, threw);
|
||||
|
||||
/* update_subscriptions is async: the LuCI button used to block inside one
|
||||
* XHR for the whole 5-15 s pipeline (wget + UCI commit + reload + health
|
||||
* gate), which exceeded the browser XHR timeout on every browser we tried.
|
||||
* The ubus method now spawns the script detached and returns immediately.
|
||||
* What the test asserts is exactly the contract the frontend relies on:
|
||||
* the call returns synchronously (without the script's exit status), the
|
||||
* result flag says the script was started, and async:true tells the
|
||||
* caller "don't wait, poll status instead".
|
||||
*
|
||||
* Asserting the lock file is present at the same instant is racy in this
|
||||
* sandbox: the early-exit path (no subscription_url) runs the whole
|
||||
* take-lock / load-state / release-lock sequence in microseconds, so the
|
||||
* lock may already be gone by the time this script runs an `access()` on
|
||||
* it. Polling the status RPC instead would be timing-dependent in the
|
||||
* same way. The contract is the return shape; that is what we pin. */
|
||||
{
|
||||
const ret = rpc.update_subscriptions.call({ args: {} });
|
||||
check('update_subscriptions returns an object', type(ret) === 'object');
|
||||
check('update_subscriptions marks the call as async',
|
||||
ret.async === true,
|
||||
sprintf('got %J', ret));
|
||||
check('update_subscriptions does not surface exit status or captured stdio',
|
||||
ret.exitcode === null && !(('stdout' in ret) || ('stderr' in ret)),
|
||||
sprintf('got %J', ret));
|
||||
|
||||
/* The companion update_subscriptions_status RPC reads the lock + the
|
||||
* last log lines. What we pin here is the *shape* of the response:
|
||||
* `running` is a boolean, `log_tail` is a string. A real
|
||||
* running/false toggle only matters when there is a live lock file,
|
||||
* which the sandbox cannot keep around long enough to observe
|
||||
* deterministically. */
|
||||
const sret = rpc.update_subscriptions_status.call({ args: {} });
|
||||
check('update_subscriptions_status returns an object', type(sret) === 'object');
|
||||
check('update_subscriptions_status.running is a bool',
|
||||
type(sret.running) === 'bool',
|
||||
sprintf('got %J', sret));
|
||||
check('update_subscriptions_status.log_tail is a string',
|
||||
type(sret.log_tail) === 'string',
|
||||
sprintf('got %J', sret));
|
||||
}
|
||||
|
||||
/* Every method rpcd exposes, so none can be shipped without ever having been
|
||||
* executed. Four of these were referenced by no test at all. */
|
||||
const all_methods = [
|
||||
'acllist_read', 'acllist_write', 'certificate_write', 'connection_check',
|
||||
'log_clean', 'node_parse', 'resources_get_version', 'resources_update',
|
||||
'singbox_generator', 'singbox_get_features', 'update_subscriptions',
|
||||
'update_subscriptions_status'
|
||||
];
|
||||
|
||||
for (let m in all_methods) {
|
||||
check(sprintf('%s is exposed with a call()', m),
|
||||
type(rpc[m]) === 'object' && type(rpc[m].call) === 'function');
|
||||
|
||||
let t = null, ret = null;
|
||||
try { ret = rpc[m].call({ args: {} }); } catch (e) { t = sprintf('%s: %s', e.type, e.message); }
|
||||
check(sprintf('%s tolerates an empty request', m), t == null, t);
|
||||
check(sprintf('%s answers with an object', m), ret == null || type(ret) === 'object',
|
||||
sprintf('got %J', ret));
|
||||
}
|
||||
|
||||
/* The argument validators must reject, not act on, an unknown value. */
|
||||
check('acllist_read rejects an unknown list type',
|
||||
rpc.acllist_read.call({ args: { type: 'not_a_list' } }).result === false ||
|
||||
rpc.acllist_read.call({ args: { type: 'not_a_list' } }).error != null);
|
||||
check('connection_check rejects an unknown site',
|
||||
rpc.connection_check.call({ args: { site: 'not_a_site' } }).result === false);
|
||||
check('log_clean rejects an unknown log type',
|
||||
rpc.log_clean.call({ args: { type: '../etc/passwd' } }).status !== 0);
|
||||
|
||||
printf('rpc methods: %d checks, %d failures\n', checks, failures);
|
||||
exit(failures ? 1 : 0);
|
||||
DRIVER
|
||||
|
||||
# The module ends with `return { 'luci.homeproxy': methods };`, which makes it a
|
||||
# program rather than an importable module - that is how rpcd runs it. Drop
|
||||
# that one line and append the driver, so the module's top-level code builds
|
||||
# `methods` and the driver drives it in the same scope. Nothing else about the
|
||||
# file changes.
|
||||
sed '$d' "$WORK/rpc.uc" > "$WORK/run.uc"
|
||||
sed -e "s#@@WORK@@#$WORK#" -e "s#@@STAGE@@#$WORK/tmp#" "$WORK/driver_body.uc" >> "$WORK/run.uc"
|
||||
|
||||
if ucode -L "$WORK/scripts" -L "$WORK" "$WORK/run.uc"; then
|
||||
echo "PASS: rpc method behaviour"
|
||||
rm -rf "$WORK"
|
||||
exit 0
|
||||
else
|
||||
echo "FAIL: rpc method behaviour"
|
||||
rm -rf "$WORK"
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/ucode
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* B1.1: unit tests for subscription/decoder.uc. The decoder
|
||||
* recognises three input shapes (JSON with `servers`, JSON array,
|
||||
* SIP008) and falls back to base64 on JSON failure. Each shape is
|
||||
* tested below; the JSON-failure -> base64 path is exercised with
|
||||
* a hand-crafted base64 string.
|
||||
*
|
||||
* Run through tests/ucode/run.sh, which stages the module next to
|
||||
* tests/ucode/mocks/homeproxy.uc (which carries decodeBase64Str).
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { decode } from 'decoder';
|
||||
|
||||
const LOG = (..._args) => {};
|
||||
|
||||
let failures = 0;
|
||||
let checks = 0;
|
||||
|
||||
function expect(name, actual, expected) {
|
||||
checks++;
|
||||
if (sprintf('%J', actual) !== sprintf('%J', expected)) {
|
||||
printf('FAIL %s: expected %J, got %J\n', name, expected, actual);
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- empty / falsy content short-circuits --- */
|
||||
expect('empty string', decode('', LOG, 'sub'), []);
|
||||
expect('null content', decode(null, LOG, 'sub'), []);
|
||||
|
||||
/* --- JSON with 'servers' key (clash proxy-provider) --- */
|
||||
/* These two shapes used to decode to [] - see the note in
|
||||
* subscription/decoder.uc: the SIP008 probe read `.server` off a
|
||||
* string, ucode threw, the JSON try/catch swallowed it and the
|
||||
* base64 fallback failed. Both are legitimate subscription
|
||||
* payloads whose entries are plain share links, so the assertion is
|
||||
* now "the links survive" rather than "the old bug is preserved"
|
||||
* (plan 3.2: do not keep a bug as the contract). */
|
||||
{
|
||||
const out = decode('{"servers":["vless://a", "trojan://b"]}', LOG, 'sub');
|
||||
expect('json servers: length', length(out), 2);
|
||||
expect('json servers: entry 0', out[0], 'vless://a');
|
||||
expect('json servers: entry 1', out[1], 'trojan://b');
|
||||
}
|
||||
|
||||
/* --- JSON array of URI strings --- */
|
||||
{
|
||||
const out = decode('["vless://a", "trojan://b"]', LOG, 'sub');
|
||||
expect('json uri array: length', length(out), 2);
|
||||
expect('json uri array: entry 1', out[1], 'trojan://b');
|
||||
}
|
||||
|
||||
/* --- mixed: SIP008 objects and share links in one list --- */
|
||||
{
|
||||
const mixed = '[{"server":"1.2.3.4","server_port":8388,'
|
||||
+ '"password":"x","method":"chacha20-ietf-poly1305"},'
|
||||
+ '"vless://a"]';
|
||||
const out = decode(mixed, LOG, 'sub');
|
||||
expect('mixed: length', length(out), 2);
|
||||
expect('mixed: object tagged', out[0].nodetype, 'sip008');
|
||||
expect('mixed: link kept', out[1], 'vless://a');
|
||||
}
|
||||
|
||||
/* --- SIP008: array of objects with server+method --- */
|
||||
{
|
||||
const sip = '[{"server":"1.2.3.4","server_port":8388,'
|
||||
+ '"password":"x","method":"chacha20-ietf-poly1305"},'
|
||||
+ '{"server":"5.6.7.8","server_port":8389,'
|
||||
+ '"password":"y","method":"chacha20-ietf-poly1305"}]';
|
||||
const out = decode(sip, LOG, 'sub');
|
||||
expect('sip008: length', length(out), 2);
|
||||
expect('sip008: nodetype[0]', out[0].nodetype, 'sip008');
|
||||
expect('sip008: nodetype[1]', out[1].nodetype, 'sip008');
|
||||
expect('sip008: server[0] kept', out[0].server, '1.2.3.4');
|
||||
}
|
||||
|
||||
/* --- base64 fallback when JSON parse fails --- */
|
||||
{
|
||||
/* "vless://first\ntrojan://second" base64-encoded. Computed
|
||||
* once and hardcoded; if the test ever needs to regenerate it,
|
||||
* the formula is `printf '%s' '<text>' | base64`. */
|
||||
const b64 = 'dmxlc3M6Ly9maXJzdAp0cm9qYW46Ly9zZWNvbmQ=';
|
||||
const out = decode(b64, LOG, 'sub');
|
||||
expect('base64: length', length(out), 2);
|
||||
expect('base64: first', out[0], 'vless://first');
|
||||
expect('base64: second', out[1], 'trojan://second');
|
||||
}
|
||||
|
||||
/* --- total failure (neither JSON nor base64) returns [] --- */
|
||||
{
|
||||
const out = decode('!@#$% not json and not base64!', LOG, 'sub');
|
||||
expect('total failure: empty', length(out), 0);
|
||||
}
|
||||
|
||||
/* Note: a *mixed* list (first entry is a SIP008 object, rest are
|
||||
* URI strings) would crash in ucode on `nodes[1].nodetype = ...`
|
||||
* because strings are immutable. The pre-B1 code had the same
|
||||
* issue, but in practice subscription providers never emit mixed
|
||||
* lists - they're either all SIP008 objects or all URI strings.
|
||||
* Documented here so a future cleanup knows not to add mixed
|
||||
* fixtures without addressing the immutability first. */
|
||||
|
||||
printf('%d checks, %d failures\n', checks, failures);
|
||||
exit(failures ? 1 : 0);
|
||||
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/ucode
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Direct unit tests for subscription/fetcher.uc. The fetcher is a
|
||||
* thin wrapper around wGETVerbose (subscription byte fetch + log +
|
||||
* return), so the unit-testable surface is narrow:
|
||||
*
|
||||
* - empty content (network error / wget returned no body):
|
||||
* - logs the failure with a redacted URL
|
||||
* - returns { content: null, error: '...' }
|
||||
* - non-empty content:
|
||||
* - returns { content: <string>, error: null } with no log call
|
||||
*
|
||||
* The wGETVerbose used by the fetcher is imported from `homeproxy`.
|
||||
* Tests stage a `homeproxy` module that overrides only wGETVerbose
|
||||
* (everything else falls through to the real homeproxy.uc, so
|
||||
* redactUrl/isEmpty are not stubbed). The override reads the
|
||||
* pre-canned response / exit code from globals - global is the
|
||||
* only cross-module shared state ucode exposes without a real
|
||||
* dependency-injection hook.
|
||||
*
|
||||
* REDACTION COVERAGE
|
||||
* ------------------
|
||||
* redactUrl() is exported from homeproxyuc and is what stops the
|
||||
* subscription token from landing in /var/run/homeproxy/homeproxy.log.
|
||||
* The fetcher is the only place that logs a subscription URL on the
|
||||
* failure path; this test makes sure the redacted URL reaches the
|
||||
* log sink rather than the original (a security regression would
|
||||
* fail here).
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { fetch } from 'fetcher';
|
||||
|
||||
let failures = 0,
|
||||
checks = 0;
|
||||
|
||||
function expect(name, actual, want) {
|
||||
checks++;
|
||||
if (sprintf('%J', actual) !== sprintf('%J', want)) {
|
||||
printf('FAIL %s: expected %J, got %J\n', name, want, actual);
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
|
||||
/* Capture the log() callback into a local buffer so we can inspect
|
||||
* what the fetcher decided to log. */
|
||||
function make_log_capture() {
|
||||
let buf = [];
|
||||
const log = (...args) => { push(buf, join(' ', args)); };
|
||||
return { buf, log };
|
||||
}
|
||||
|
||||
/* Test 1: empty content -> failure logged + null returned.
|
||||
* Global override returns { content: '', error: '...' }. */
|
||||
{
|
||||
const cap = make_log_capture();
|
||||
/* globals stub */
|
||||
global.HP_TEST_WGET_CONTENT = '';
|
||||
global.HP_TEST_WGET_ERROR = 'connection refused';
|
||||
const r = fetch('https://user:tok@host.example.com/path?q=token=secret', null, cap.log);
|
||||
expect('empty.content', r.content, null);
|
||||
expect('empty.error is what wGETVerbose returned', r.error, 'connection refused');
|
||||
if (length(cap.buf) !== 1) {
|
||||
printf('FAIL empty.log count: expected 1, got %d\n', length(cap.buf));
|
||||
failures++;
|
||||
} else {
|
||||
checks++;
|
||||
/* The URL in the log must be redacted: no user:tok, no token=secret. */
|
||||
const line = cap.buf[0];
|
||||
if (index(line, 'tok') !== -1 || index(line, 'token=secret') !== -1) {
|
||||
printf('FAIL empty.log redaction: token leaked: %s\n', line);
|
||||
failures++;
|
||||
} else {
|
||||
checks++;
|
||||
printf('PASS empty.log redaction: %s\n', line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Test 2: non-empty content -> no log, body round-trips, error null. */
|
||||
{
|
||||
const cap = make_log_capture();
|
||||
global.HP_TEST_WGET_CONTENT = 'stub-body';
|
||||
global.HP_TEST_WGET_ERROR = null;
|
||||
const r = fetch('https://host.example.com/path?q=anything', null, cap.log);
|
||||
expect('ok.content', r.content, 'stub-body');
|
||||
expect('ok.error', r.error, null);
|
||||
if (length(cap.buf) !== 0) {
|
||||
printf('FAIL ok.log: expected 0, got %d (%J)\n', length(cap.buf), cap.buf);
|
||||
failures++;
|
||||
} else {
|
||||
checks++;
|
||||
}
|
||||
}
|
||||
|
||||
/* Test 3: the path carries the token too. Many providers hand out
|
||||
* https://host:port/<user>/<token> with no query string at all - that is
|
||||
* what the device this was found on is configured with - and the redaction
|
||||
* used to keep the path while claiming homeproxy.log is world-readable.
|
||||
* The host and port have to survive, or the message stops being useful. */
|
||||
{
|
||||
const cap = make_log_capture();
|
||||
global.HP_TEST_WGET_CONTENT = '';
|
||||
global.HP_TEST_WGET_ERROR = 'connection refused';
|
||||
const r = fetch('https://bwg.example.net:2345/wjp/SECRETPATHTOKEN', null, cap.log);
|
||||
expect('path.content', r.content, null);
|
||||
if (length(cap.buf) !== 1) {
|
||||
printf('FAIL path.log count: expected 1, got %d\n', length(cap.buf));
|
||||
failures++;
|
||||
} else {
|
||||
checks++;
|
||||
const line = cap.buf[0];
|
||||
if (index(line, 'SECRETPATHTOKEN') !== -1 || index(line, 'wjp') !== -1) {
|
||||
printf('FAIL path.log redaction: the path token leaked: %s\n', line);
|
||||
failures++;
|
||||
} else if (index(line, 'bwg.example.net:2345') === -1) {
|
||||
printf('FAIL path.log redaction: host and port must survive: %s\n', line);
|
||||
failures++;
|
||||
} else {
|
||||
checks++;
|
||||
printf('PASS path.log redaction: %s\n', line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* --- summary ----------------------------------------------------------- */
|
||||
|
||||
if (failures > 0)
|
||||
printf('FAIL: %d/%d checks failed\n', failures, checks);
|
||||
else
|
||||
printf('PASS: %d checks\n', checks);
|
||||
|
||||
exit(failures > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/ucode
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Unit tests for subscription/filter.uc. Both check() and
|
||||
* apply_policy() are pure - no UCI access, no module state - so
|
||||
* the tests can hand them ordinary values and a no-op logger.
|
||||
* apply_policy() takes a canonical Node, so the equivalence block at
|
||||
* the end also drives the real normalize()/flatten() pair to prove the
|
||||
* move off the flat dict did not change what reaches UCI.
|
||||
*
|
||||
* Run through tests/ucode/run.sh, which stages the module next to
|
||||
* tests/ucode/mocks/homeproxy.uc so the `from 'homeproxy'` import
|
||||
* resolves to the test double, and parser/ next to the test so the
|
||||
* round-trip imports resolve.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { check, apply_policy } from 'filter';
|
||||
import { normalize } from './parser/normalize.uc';
|
||||
import { flatten } from './parser/flatten.uc';
|
||||
|
||||
const LOG = (..._args) => {};
|
||||
|
||||
let failures = 0;
|
||||
let checks = 0;
|
||||
|
||||
function expect(name, actual, expected) {
|
||||
checks++;
|
||||
if (sprintf('%J', actual) !== sprintf('%J', expected)) {
|
||||
printf('FAIL %s: expected %J, got %J\n', name, expected, actual);
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- check(): disabled mode passes everything through --- */
|
||||
expect('disabled + match', check('foo', 'disabled', ['foo'], LOG), false);
|
||||
expect('disabled + miss', check('foo-bar', 'disabled', ['baz'], LOG), false);
|
||||
expect('disabled + no kw', check('foo', 'disabled', [], LOG), false);
|
||||
|
||||
/* --- check(): empty inputs short-circuit --- */
|
||||
expect('empty name', check(null, 'blacklist', ['x'], LOG), false);
|
||||
expect('empty keywords', check('foo', 'blacklist', [], LOG), false);
|
||||
|
||||
/* --- check(): blacklist --- */
|
||||
expect('blacklist hit', check('foo-bar', 'blacklist', ['bar'], LOG), true);
|
||||
expect('blacklist miss', check('foo-bar', 'blacklist', ['baz'], LOG), false);
|
||||
expect('blacklist multi-hit', check('foo-bar', 'blacklist', ['baz','bar'],LOG), true);
|
||||
expect('blacklist invalid-rx skipped',
|
||||
check('foo', 'blacklist', ['[unterminated'], LOG), false);
|
||||
|
||||
/* --- check(): whitelist inverts the result --- */
|
||||
expect('whitelist hit (kept)', check('foo-bar', 'whitelist', ['bar'], LOG), false);
|
||||
expect('whitelist miss (dropp)', check('foo-bar', 'whitelist', ['baz'], LOG), true);
|
||||
/* whitelist with no keywords: the early-return guard (isEmpty(keywords))
|
||||
* fires before the whitelist inversion, so the node is kept. Documenting
|
||||
* this here so a future cleanup of the early-return does not silently
|
||||
* change the behaviour. */
|
||||
expect('whitelist empty kw (kept)', check('foo-bar', 'whitelist', [], LOG), false);
|
||||
|
||||
/* --- apply_policy(): canonical Node, tls.insecure override --- */
|
||||
{
|
||||
const node = apply_policy(
|
||||
{ type: 'vless', tls: { enabled: '1' }, protocol_options: {} },
|
||||
{ allow_insecure: '1', packet_encoding: 'xudp' }
|
||||
);
|
||||
expect('tls.enabled=1 + allow_insecure=1 sets tls.insecure', node.tls.insecure, '1');
|
||||
expect('tls.enabled=1 + allow_insecure=1 also sets packet_encoding',
|
||||
node.protocol_options.packet_encoding, 'xudp');
|
||||
}
|
||||
{
|
||||
const node = apply_policy(
|
||||
{ type: 'vless', tls: { enabled: '1' }, protocol_options: {} },
|
||||
{ allow_insecure: '0', packet_encoding: 'xudp' }
|
||||
);
|
||||
expect('tls.enabled=1 + allow_insecure=0 leaves tls.insecure absent',
|
||||
'insecure' in node.tls, false);
|
||||
}
|
||||
{
|
||||
const node = apply_policy(
|
||||
{ type: 'vless', tls: { enabled: '0' }, protocol_options: {} },
|
||||
{ allow_insecure: '1', packet_encoding: 'xudp' }
|
||||
);
|
||||
expect('tls.enabled=0 + allow_insecure=1 leaves tls.insecure absent',
|
||||
'insecure' in node.tls, false);
|
||||
}
|
||||
|
||||
/* --- apply_policy(): packet_encoding only for vless/vmess --- */
|
||||
{
|
||||
const node = apply_policy(
|
||||
{ type: 'trojan', tls: { enabled: '0' }, protocol_options: {} },
|
||||
{ allow_insecure: '0', packet_encoding: 'xudp' }
|
||||
);
|
||||
expect('trojan + packet_encoding not set',
|
||||
'packet_encoding' in node.protocol_options, false);
|
||||
}
|
||||
{
|
||||
const node = apply_policy(
|
||||
{ type: 'vmess', tls: { enabled: '0' }, protocol_options: {} },
|
||||
{ allow_insecure: '0', packet_encoding: 'xudp' }
|
||||
);
|
||||
expect('vmess + packet_encoding set', node.protocol_options.packet_encoding, 'xudp');
|
||||
}
|
||||
|
||||
/* A node that did not go through normalize() has no protocol_options; the flat
|
||||
* version created that key unconditionally, so this must too. */
|
||||
{
|
||||
const node = apply_policy({ type: 'vless', tls: { enabled: '0' } },
|
||||
{ allow_insecure: '0', packet_encoding: 'xudp' });
|
||||
expect('a node without protocol_options gets one',
|
||||
node.protocol_options.packet_encoding, 'xudp');
|
||||
}
|
||||
|
||||
/* --- apply_policy(): null / undefined guard --- */
|
||||
expect('apply_policy(null) returns null',
|
||||
apply_policy(null, { allow_insecure: '0', packet_encoding: 'xudp' }), null);
|
||||
|
||||
/* --- equivalence: flat order and canonical order write the same UCI --------
|
||||
*
|
||||
* The policy step used to run on the parser's flat dict with normalize() after
|
||||
* it; it runs on the canonical Node now. What reaches the router is
|
||||
* flatten(node), so the two orders are equivalent exactly when that output is
|
||||
* identical - asserted here for every combination the tweaks can produce.
|
||||
*
|
||||
* The old order is written out as a local helper rather than left in the
|
||||
* module: keeping a copy of the thing being replaced is what makes this an
|
||||
* equivalence proof instead of a restatement of the new code.
|
||||
*/
|
||||
function legacy_flat_policy(flat, opts) {
|
||||
if (flat.tls === '1' && opts.allow_insecure === '1')
|
||||
flat.tls_insecure = '1';
|
||||
|
||||
if (flat.type in ['vless', 'vmess'])
|
||||
flat.packet_encoding = opts.packet_encoding;
|
||||
|
||||
return flat;
|
||||
}
|
||||
|
||||
/* Fresh object per call: apply_policy mutates, so a shared fixture would leak
|
||||
* one case's tweaks into the next. */
|
||||
function flat_vless() {
|
||||
return { type: 'vless', address: 'a.example.com', port: '443', label: 'n1',
|
||||
tls: '1', tls_sni: 'a.example.com',
|
||||
uuid: '11111111-2222-3333-4444-555555555555', flow: 'xtls-rprx-vision' };
|
||||
}
|
||||
function flat_vless_notls() {
|
||||
return { type: 'vless', address: 'a.example.com', port: '443', label: 'n1',
|
||||
tls: '0', uuid: 'u' };
|
||||
}
|
||||
function flat_vmess() {
|
||||
return { type: 'vmess', address: 'b.example.com', port: '8443', label: 'n2',
|
||||
tls: '0', uuid: 'u' };
|
||||
}
|
||||
function flat_trojan() {
|
||||
return { type: 'trojan', address: 'c.example.com', port: '443', label: 'n3',
|
||||
tls: '1', password: 'p' };
|
||||
}
|
||||
|
||||
function equivalence(name, fixture, opts) {
|
||||
const legacy = flatten(normalize(legacy_flat_policy(fixture(), opts)));
|
||||
const node = normalize(fixture());
|
||||
apply_policy(node, opts);
|
||||
expect(name, sprintf('%J', flatten(node)), sprintf('%J', legacy));
|
||||
}
|
||||
|
||||
equivalence('equivalence: vless + tls + allow_insecure', flat_vless,
|
||||
{ allow_insecure: '1', packet_encoding: 'xudp' });
|
||||
equivalence('equivalence: vless + tls, allow_insecure off', flat_vless,
|
||||
{ allow_insecure: '0', packet_encoding: 'xudp' });
|
||||
equivalence('equivalence: vless without tls, allow_insecure on', flat_vless_notls,
|
||||
{ allow_insecure: '1', packet_encoding: 'packet' });
|
||||
equivalence('equivalence: vmess + packet_encoding', flat_vmess,
|
||||
{ allow_insecure: '1', packet_encoding: 'xudp' });
|
||||
equivalence('equivalence: trojan, no packet_encoding path', flat_trojan,
|
||||
{ allow_insecure: '1', packet_encoding: 'xudp' });
|
||||
|
||||
/* Both sides could drop the tweak and still be "equivalent", so assert the
|
||||
* tweaks actually reach the UCI-key output. */
|
||||
{
|
||||
const node = normalize(flat_vless());
|
||||
apply_policy(node, { allow_insecure: '1', packet_encoding: 'xudp' });
|
||||
const out = flatten(node);
|
||||
expect('the tweaks reach the UCI-key output (tls_insecure)', out.tls_insecure, '1');
|
||||
expect('the tweaks reach the UCI-key output (packet_encoding)',
|
||||
out.packet_encoding, 'xudp');
|
||||
}
|
||||
|
||||
printf('%d checks, %d failures\n', checks, failures);
|
||||
exit(failures ? 1 : 0);
|
||||
@@ -0,0 +1,82 @@
|
||||
#!/bin/sh
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# B1.2: integration test for subscription/repository.uc. Unlike
|
||||
# the filter/decoder unit tests, this one writes to a real UCI
|
||||
# cursor, so the runner stages a sandboxed config dir with a
|
||||
# pre-existing homeproxy file and points the cursor at it.
|
||||
#
|
||||
# Staging layout:
|
||||
# $WORK/sandbox/homeproxy UCI file with seed sections
|
||||
# $WORK/sandbox/ cursor root
|
||||
# $WORK/scripts/ test + homeproxy mock + module
|
||||
#
|
||||
# cursor(dir) reads from <dir>/<name> directly (NOT <dir>/config/
|
||||
# <name>), so the seed file lives at the top of the sandbox dir.
|
||||
#
|
||||
# Run from tests/ucode/run.sh, which exports UCODE_MODULES_DIR so
|
||||
# `import { cursor } from 'uci'` and `import { md5 } from 'digest'`
|
||||
# resolve against the local testbed modules.
|
||||
#
|
||||
# Usage: sh tests/ucode/test_subscription_repository.sh <repo-root> [work-dir]
|
||||
|
||||
ROOT="${1:-.}"
|
||||
WORK="${2:-/tmp/hp-subscription-repo-test}"
|
||||
|
||||
ROOT="$(cd "$ROOT" && pwd)"
|
||||
FAILED=0
|
||||
|
||||
SANDBOX="$WORK/sandbox"
|
||||
STAGE="$WORK/scripts"
|
||||
|
||||
rm -rf "$WORK"
|
||||
mkdir -p "$SANDBOX" "$STAGE"
|
||||
|
||||
# Seed the UCI file. The cursor reads <sandbox>/homeproxy (NOT
|
||||
# <sandbox>/config/homeproxy); see the comment above.
|
||||
uci_seed() {
|
||||
printf 'config homeproxy %s\n' "$1" >> "$SANDBOX/homeproxy"
|
||||
shift
|
||||
for kv in "$@"; do
|
||||
key="${kv%%=*}"
|
||||
val="${kv#*=}"
|
||||
printf '\toption %s %s\n' "$key" "$val" >> "$SANDBOX/homeproxy"
|
||||
done
|
||||
}
|
||||
|
||||
# The real /etc/config/homeproxy always carries `config homeproxy 'config'`;
|
||||
# the sandbox needs it too, because libuci's uci_set() creates an anonymous
|
||||
# section when the named one does not exist, and the main_node assertions
|
||||
# read the options back by the section name 'config'.
|
||||
uci_seed config routing_mode=bypass_mainland_china main_node=urltest
|
||||
|
||||
uci_seed cfgUSER0001 label=user-only type=vless address=user.example.com
|
||||
uci_seed cfgKEEP00001 label=kept-node grouphash=test-group type=vless \
|
||||
address=old.example.com stale_field=remove-me
|
||||
uci_seed cfgDROP00001 label=dropped-node grouphash=test-group type=vless \
|
||||
address=gone.example.com
|
||||
|
||||
# Stage the mock + the module + the test alongside each other so
|
||||
# bare-name imports (`from 'homeproxy'`, `from 'repository'`) resolve
|
||||
# via the work-dir -L path.
|
||||
#
|
||||
# PR-03: Repository imports parser/flatten.uc, which in turn imports
|
||||
# parser/mapping.uc. Stage the whole parser/ tree into a sibling
|
||||
# `parser/` directory so the bare-name imports inside it resolve.
|
||||
cp "$ROOT/tests/ucode/mocks/homeproxy.uc" "$STAGE/"
|
||||
# repository.uc imports '../parser/flatten.uc', so parser/ must be a
|
||||
# sibling of the directory holding repository.uc (STAGE/subscription/),
|
||||
# not of STAGE itself.
|
||||
mkdir -p "$STAGE/subscription" "$STAGE/parser"
|
||||
cp "$ROOT/root/etc/homeproxy/scripts/parser/"*.uc "$STAGE/parser/"
|
||||
cp "$ROOT/root/etc/homeproxy/scripts/subscription/repository.uc" "$STAGE/subscription/"
|
||||
cp "$ROOT/tests/ucode/test_subscription_repository.uc" "$STAGE/"
|
||||
|
||||
if ( cd "$STAGE" && ucode -L "$STAGE" test_subscription_repository.uc "$SANDBOX" ); then
|
||||
echo "PASS: subscription repository integration test"
|
||||
else
|
||||
echo "FAIL: subscription repository integration test"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
exit $FAILED
|
||||
@@ -0,0 +1,411 @@
|
||||
#!/usr/bin/ucode
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* PR-03 (Subscription Transaction Boundary): integration test for
|
||||
* subscription/repository.uc. The repository writes to the UCI
|
||||
* cursor that is passed in, which means the test needs a sandboxed
|
||||
* config dir. tests/ucode/test_subscription_repository.sh stages
|
||||
* the dir + the pre-existing UCI file and points the cursor at the
|
||||
* staging root. The test then exercises the four paths the
|
||||
* orchestrator relies on:
|
||||
*
|
||||
* - a user-created node (no grouphash) is preserved
|
||||
* - a subscription node still in the cache is updated in place
|
||||
* - a subscription node dropped from the cache is deleted
|
||||
* - a new node in the cache but not in UCI is added under the
|
||||
* md5(groupHash + label) section name
|
||||
*
|
||||
* PR-03 changes the input contract: Repository now accepts
|
||||
* canonical Node objects (post- parser/normalize), and flattens
|
||||
* them via parser/flatten internally. The orchestrator hands
|
||||
* Repository.normalize(parse_uri(uri)) directly; the test builds
|
||||
* the canonical shape explicitly.
|
||||
*
|
||||
* Returns 0 on success, 1 on any mismatch. Counts and section
|
||||
* names are checked with cursor.get_all() against the live cursor
|
||||
* so the test does not need to parse the UCI file by hand.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { cursor } from 'uci';
|
||||
import { md5 } from 'digest';
|
||||
import { readfile } from 'fs';
|
||||
|
||||
import { Repository } from './subscription/repository.uc';
|
||||
|
||||
/* PR-03: helper for building canonical Node fixtures. The
|
||||
* orchestrator gets canonical Nodes from normalize(parse_uri(...));
|
||||
* the test feeds them by hand so the repository's input contract
|
||||
* (canonical Node, not flat UCI) is what gets exercised. */
|
||||
function canonical_node(opts) {
|
||||
return {
|
||||
id: null,
|
||||
name: opts.label,
|
||||
type: opts.type,
|
||||
address: opts.address,
|
||||
port: opts.port || null,
|
||||
common: {},
|
||||
credentials: {},
|
||||
tls: { enabled: null, ech: {}, utls: {}, reality: {} },
|
||||
transport: { type: null },
|
||||
multiplex: { enabled: null, brutal: {} },
|
||||
protocol_options: {},
|
||||
grouphash: opts.grouphash
|
||||
/* No `label`: normalize() sets `name` and the orchestrator adds
|
||||
* `label` only when carrying it over. This helper used to set
|
||||
* `label` too, which is exactly why the section-name collision
|
||||
* below went unnoticed - it made the fixture agree with the
|
||||
* repository instead of with the model. */
|
||||
};
|
||||
}
|
||||
|
||||
const CFG = 'homeproxy';
|
||||
const TYPE = 'node';
|
||||
|
||||
/* A2 (single commit): the repository must stage mutations and commit nothing.
|
||||
* Unlike the cursor-based checks below, "did it commit" cannot be read out of
|
||||
* the cursor - a commit is exactly what makes the cursor and the file agree -
|
||||
* so the assertion is behavioural: the file on disk is byte-identical before
|
||||
* and after each repository call, and only the test's own commit (standing in
|
||||
* for the orchestrator's) makes the change durable. */
|
||||
const CONFIG_PATH = ARGV[0] + '/' + CFG;
|
||||
|
||||
/* apply_main_node_refs() reports the urltest prune through the log()
|
||||
* callback and the main_node switches through the returned result.log,
|
||||
* so the test needs to see both. */
|
||||
const seen = [];
|
||||
const LOG = (...args) => push(seen, join(' ', args));
|
||||
|
||||
let failures = 0;
|
||||
let checks = 0;
|
||||
|
||||
function expect(name, actual, expected) {
|
||||
checks++;
|
||||
if (sprintf('%J', actual) !== sprintf('%J', expected)) {
|
||||
printf('FAIL %s: expected %J, got %J\n', name, expected, actual);
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
|
||||
const uci = cursor(ARGV[0]);
|
||||
uci.load(CFG);
|
||||
|
||||
/* groupHash is what the orchestrator hands the repository. It
|
||||
* is a per-subscription-url identifier - any opaque string the
|
||||
* orchestrator likes, as long as it stays stable across fetches.
|
||||
* Two distinct URLs would get two distinct groupHash values, so
|
||||
* the test uses a single group to focus on the per-node logic. */
|
||||
const groupHash = 'test-group';
|
||||
|
||||
/* Pre-existing UCI state: a user node, a subscription node that
|
||||
* the new cache will update, and a subscription node the new
|
||||
* cache will drop. The cursor.commit() below persists them so
|
||||
* the repository's foreach() sees them when it runs. */
|
||||
const u_user = 'cfgUSER0001';
|
||||
const u_keep = 'cfgKEEP00001';
|
||||
const u_drop = 'cfgDROP00001';
|
||||
|
||||
uci.set(CFG, u_user, TYPE);
|
||||
uci.set(CFG, u_user, 'label', 'user-only');
|
||||
uci.set(CFG, u_user, 'type', 'vless');
|
||||
uci.set(CFG, u_user, 'address', 'user.example.com');
|
||||
|
||||
uci.set(CFG, u_keep, TYPE);
|
||||
uci.set(CFG, u_keep, 'label', 'kept-node');
|
||||
uci.set(CFG, u_keep, 'grouphash', groupHash);
|
||||
uci.set(CFG, u_keep, 'type', 'vless');
|
||||
uci.set(CFG, u_keep, 'address', 'old.example.com');
|
||||
uci.set(CFG, u_keep, 'stale_field', 'remove-me');
|
||||
|
||||
uci.set(CFG, u_drop, TYPE);
|
||||
uci.set(CFG, u_drop, 'label', 'dropped-node');
|
||||
uci.set(CFG, u_drop, 'grouphash', groupHash);
|
||||
uci.set(CFG, u_drop, 'type', 'vless');
|
||||
uci.set(CFG, u_drop, 'address', 'gone.example.com');
|
||||
|
||||
uci.commit(CFG);
|
||||
|
||||
/* What the orchestrator would have built during the fetch phase
|
||||
* for a successful subscription update. Two canonical Nodes from
|
||||
* the cache:
|
||||
* - the updated version of u_keep (with new address)
|
||||
* - a brand-new node (will be added under md5(group+label))
|
||||
* u_drop is intentionally absent -> the repository must delete it. */
|
||||
const kept_updated = canonical_node({
|
||||
grouphash: groupHash,
|
||||
label: 'kept-node',
|
||||
type: 'vless',
|
||||
address: 'new.example.com',
|
||||
port: '443'
|
||||
});
|
||||
|
||||
const new_label = 'fresh-node';
|
||||
const new_node = canonical_node({
|
||||
grouphash: groupHash,
|
||||
label: new_label,
|
||||
type: 'trojan',
|
||||
address: 'fresh.example.com',
|
||||
port: '8443'
|
||||
});
|
||||
|
||||
const node_cache = {
|
||||
[groupHash]: {
|
||||
[u_keep]: kept_updated,
|
||||
[md5(groupHash + new_label)]: new_node
|
||||
}
|
||||
};
|
||||
|
||||
const node_result = [[kept_updated, new_node]];
|
||||
|
||||
const disk_before_apply = readfile(CONFIG_PATH);
|
||||
const result = Repository.apply_nodes(uci, CFG, TYPE, node_cache, node_result, LOG);
|
||||
expect('counts: added', result.added, 1);
|
||||
expect('counts: removed', result.removed, 1);
|
||||
expect('commit ownership: apply_nodes left the file untouched',
|
||||
readfile(CONFIG_PATH), disk_before_apply);
|
||||
|
||||
/* The caller's single transaction boundary (A2). Everything below reads the
|
||||
* on-disk state, so this commit is what makes it visible. */
|
||||
uci.commit(CFG);
|
||||
|
||||
/* Reload to see the on-disk state. */
|
||||
uci.load(CFG);
|
||||
|
||||
/* User node preserved verbatim. */
|
||||
{
|
||||
const cfg = uci.get_all(CFG, u_user);
|
||||
expect('user: section exists', 'address' in cfg, true);
|
||||
expect('user: address kept', cfg.address, 'user.example.com');
|
||||
expect('user: no grouphash leaked in',
|
||||
'grouphash' in cfg, false);
|
||||
}
|
||||
|
||||
/* Updated node: the new address is written, the field the new config
|
||||
* no longer carries is dropped, and a field that exists only in the
|
||||
* new config (port, in this fixture) is ADDED. The old
|
||||
* implementation walked only the stored section's keys, so a
|
||||
* subscription that started sending a new option could never update
|
||||
* an existing node; that behaviour was previously locked in here as a
|
||||
* "quirk" and is now asserted to be fixed. */
|
||||
{
|
||||
const cfg = uci.get_all(CFG, u_keep);
|
||||
expect('kept: section exists', 'address' in cfg, true);
|
||||
expect('kept: address updated', cfg.address, 'new.example.com');
|
||||
expect('kept: stale_field gone', 'stale_field' in cfg, false);
|
||||
expect('kept: new field added', cfg.port, '443');
|
||||
}
|
||||
|
||||
/* Dropped node is gone. */
|
||||
{
|
||||
const cfg = uci.get_all(CFG, u_drop);
|
||||
/* get_all on a deleted section returns the section metadata
|
||||
* (with .name) but no actual options. The cleanest check is
|
||||
* that the label is no longer present. */
|
||||
expect('drop: section removed', 'label' in cfg, false);
|
||||
}
|
||||
|
||||
/* New node added under the md5(group+label) name. */
|
||||
{
|
||||
const nameHash = md5(groupHash + new_label);
|
||||
const cfg = uci.get_all(CFG, nameHash);
|
||||
expect('new: section added', 'address' in cfg, true);
|
||||
expect('new: address correct', cfg.address, 'fresh.example.com');
|
||||
expect('new: type correct', cfg.type, 'trojan');
|
||||
expect('new: grouphash set', cfg.grouphash, groupHash);
|
||||
}
|
||||
|
||||
/* --- PR-03: apply_main_node_refs ------------------------------------ */
|
||||
|
||||
/* Use a fresh cursor so the apply_nodes state above does not pollute the
|
||||
* urltest-list assertions below. */
|
||||
const uci2 = cursor(ARGV[0]);
|
||||
uci2.load(CFG);
|
||||
|
||||
/* The sandbox's first `node` section (file order) is cfgUSER0001, which
|
||||
* is what apply_main_node_refs() falls back to when a main_node target is
|
||||
* gone. Assert against it explicitly rather than hard-coding a value that
|
||||
* only holds for one sandbox layout. */
|
||||
const FIRST_NODE = 'cfgUSER0001';
|
||||
|
||||
const groupB = 'groupB';
|
||||
const n_alive = 'cfgALIVE0001';
|
||||
/* Deliberately never created: the point of the prune case is an
|
||||
* urltest_nodes entry whose section no longer exists. */
|
||||
const n_gone = 'cfgGONE00001';
|
||||
|
||||
uci2.set(CFG, n_alive, TYPE);
|
||||
uci2.set(CFG, n_alive, 'label', 'alive-node');
|
||||
uci2.set(CFG, n_alive, 'grouphash', groupB);
|
||||
uci2.set(CFG, n_alive, 'type', 'vless');
|
||||
uci2.set(CFG, n_alive, 'address', 'alive.example.com');
|
||||
|
||||
uci2.set(CFG, 'config', 'main_node', 'urltest');
|
||||
uci2.set(CFG, 'config', 'main_urltest_nodes', [n_alive, n_gone]);
|
||||
uci2.commit(CFG);
|
||||
uci2.load(CFG);
|
||||
|
||||
const disk_before_refs = readfile(CONFIG_PATH);
|
||||
const main_refs = Repository.apply_main_node_refs(
|
||||
uci2, CFG, 'config', TYPE,
|
||||
{ main_node: 'urltest', main_udp_node: 'nil', has_nodes: true },
|
||||
LOG
|
||||
);
|
||||
expect('main_node_refs: main_node kept as urltest',
|
||||
main_refs.main_node, 'urltest');
|
||||
expect('commit ownership: apply_main_node_refs left the file untouched',
|
||||
readfile(CONFIG_PATH), disk_before_refs);
|
||||
|
||||
/* The caller's transaction boundary again (A2); the repository never commits,
|
||||
* not even on the reconcile paths that used to commit for themselves. */
|
||||
uci2.commit(CFG);
|
||||
uci2.load(CFG);
|
||||
expect('main_node_refs: dead reference pruned',
|
||||
sort(uci2.get(CFG, 'config', 'main_urltest_nodes') || []), [n_alive]);
|
||||
expect('main_node_refs: log line emitted for the prune',
|
||||
length(filter(seen, (l) => match(l, /removing from urltest/))),
|
||||
1);
|
||||
|
||||
/* "main_node target is gone": point main_node at a section that does not
|
||||
* exist. The Repository must switch to the first node in the file. */
|
||||
uci2.set(CFG, 'config', 'main_node', 'cfgNOPEEEEEE');
|
||||
uci2.commit(CFG);
|
||||
uci2.load(CFG);
|
||||
|
||||
const disk_before_refs2 = readfile(CONFIG_PATH);
|
||||
const main_refs2 = Repository.apply_main_node_refs(
|
||||
uci2, CFG, 'config', TYPE,
|
||||
{ main_node: 'cfgNOPEEEEEE', main_udp_node: 'nil', has_nodes: true },
|
||||
LOG
|
||||
);
|
||||
expect('main_node_refs: missing target switched to first_server',
|
||||
main_refs2.main_node, FIRST_NODE);
|
||||
expect('main_node_refs: switch logged',
|
||||
length(filter(main_refs2.log, (l) => match(l, /switching to/))),
|
||||
1);
|
||||
/* A2: the switch is staged, not committed. */
|
||||
expect('commit ownership: the main_node switch is not committed by the repository',
|
||||
readfile(CONFIG_PATH), disk_before_refs2);
|
||||
uci2.commit(CFG);
|
||||
uci2.load(CFG);
|
||||
expect('main_node_refs: switch persisted to UCI',
|
||||
uci2.get(CFG, 'config', 'main_node'), FIRST_NODE);
|
||||
|
||||
/* The "no nodes at all" reset path needs a node-free config: delete every
|
||||
* node section, then run the reconcile with main_node set. */
|
||||
for (let s in (uci2.get(CFG, 'config') ? [] : []))
|
||||
;
|
||||
uci2.foreach(CFG, TYPE, (section) => uci2.delete(CFG, section['.name']));
|
||||
uci2.commit(CFG);
|
||||
uci2.load(CFG);
|
||||
|
||||
expect('main_node_refs: no node sections left',
|
||||
uci2.get_first(CFG, TYPE), null);
|
||||
|
||||
const disk_before_refs3 = readfile(CONFIG_PATH);
|
||||
const main_refs3 = Repository.apply_main_node_refs(
|
||||
uci2, CFG, 'config', TYPE,
|
||||
{ main_node: 'urltest', main_udp_node: 'urltest', has_nodes: false },
|
||||
LOG
|
||||
);
|
||||
expect('main_node_refs: no-nodes path resets main_node to nil',
|
||||
main_refs3.main_node, 'nil');
|
||||
expect('main_node_refs: no-nodes path resets main_udp_node to nil',
|
||||
main_refs3.main_udp_node, 'nil');
|
||||
/* A2: the reset-to-nil path used to commit for itself; it must not any more. */
|
||||
expect('main_node_refs: no-nodes path did not commit',
|
||||
readfile(CONFIG_PATH), disk_before_refs3);
|
||||
uci2.commit(CFG);
|
||||
uci2.load(CFG);
|
||||
expect('main_node_refs: no-nodes path wrote nil to UCI',
|
||||
uci2.get(CFG, 'config', 'main_node'), 'nil');
|
||||
expect('main_node_refs: no-nodes path wrote nil to UCI for udp',
|
||||
uci2.get(CFG, 'config', 'main_udp_node'), 'nil');
|
||||
expect('main_node_refs: no-nodes path logged the disable',
|
||||
length(filter(main_refs3.log, (l) => match(l, /disable tproxy/))),
|
||||
1);
|
||||
|
||||
/* --- PR-03: scrub_stale_urltest_refs ------------------------------- */
|
||||
|
||||
/* A routing_node whose urltest_nodes mixes a live and a missing node. */
|
||||
const rn_x = 'rn_test';
|
||||
const n_other = 'cfgOTHER0001';
|
||||
uci2.set(CFG, n_other, TYPE);
|
||||
uci2.set(CFG, n_other, 'label', 'other-node');
|
||||
uci2.set(CFG, n_other, 'type', 'vless');
|
||||
uci2.set(CFG, n_other, 'address', 'other.example.com');
|
||||
|
||||
uci2.set(CFG, rn_x, 'routing_node');
|
||||
uci2.set(CFG, rn_x, 'node', 'urltest');
|
||||
uci2.set(CFG, rn_x, 'urltest_nodes', [n_other, n_gone]);
|
||||
uci2.commit(CFG);
|
||||
uci2.load(CFG);
|
||||
|
||||
const disk_before_scrub = readfile(CONFIG_PATH);
|
||||
const scrub = Repository.scrub_stale_urltest_refs(uci2, CFG, LOG);
|
||||
expect('scrub: one routing_node rewritten', scrub.changed, 1);
|
||||
expect('commit ownership: scrub left the file untouched',
|
||||
readfile(CONFIG_PATH), disk_before_scrub);
|
||||
uci2.commit(CFG);
|
||||
uci2.load(CFG);
|
||||
expect('scrub: live ref preserved',
|
||||
sort(uci2.get(CFG, rn_x, 'urltest_nodes') || []), [n_other]);
|
||||
|
||||
/* An already-clean routing_node must not be rewritten. */
|
||||
const scrub2 = Repository.scrub_stale_urltest_refs(uci2, CFG, LOG);
|
||||
expect('scrub: clean state changes nothing', scrub2.changed, 0);
|
||||
|
||||
/* --- RFC 1321 MD5 vectors: the cross-language half of the contract ------ */
|
||||
|
||||
/* update_subscriptions.uc computes a node's `grouphash` as md5(url, fragment
|
||||
* stripped), and the Repository above names each section
|
||||
* md5(grouphash + label). The browser compares *its own* MD5 -
|
||||
* homeproxy.js calcStringMD5(), which cannot use crypto.subtle because the
|
||||
* Web Crypto spec has no MD5 - against that grouphash to decide which
|
||||
* subscription tab a section belongs to, and whether it is a subscription
|
||||
* node at all.
|
||||
*
|
||||
* So the two languages have to agree exactly, and nothing in the suite
|
||||
* checked it: there was no MD5 vector anywhere, on either side. The failure
|
||||
* mode is quiet - a digest that does not match does not raise, it just drops
|
||||
* the subscription's nodes out of their tab. tests/frontend-md5.js pins the
|
||||
* browser side to these same vectors; this block pins the ucode side, which
|
||||
* is what turns "each side is standard" into "the two sides are equal".
|
||||
*
|
||||
* The last three cover what the browser implementation used to get wrong: it
|
||||
* normalised CRLF to LF before hashing, and it encoded UTF-8 one UTF-16 code
|
||||
* unit at a time. They are built with chr() rather than spelled as literals
|
||||
* so the bytes under test are unambiguous:
|
||||
*
|
||||
* U+4E2D U+6587 -> e4 b8 ad e6 96 87
|
||||
* U+1F600 -> f0 9f 98 80 */
|
||||
const MD5_CJK = chr(0xe4) + chr(0xb8) + chr(0xad) + chr(0xe6) + chr(0x96) + chr(0x87);
|
||||
const MD5_EMOJI = chr(0xf0) + chr(0x9f) + chr(0x98) + chr(0x80);
|
||||
|
||||
expect('md5: RFC 1321 A.5 ""', md5(''), 'd41d8cd98f00b204e9800998ecf8427e');
|
||||
expect('md5: RFC 1321 A.5 "a"', md5('a'), '0cc175b9c0f1b6a831c399e269772661');
|
||||
expect('md5: RFC 1321 A.5 "abc"', md5('abc'), '900150983cd24fb0d6963f7d28e17f72');
|
||||
expect('md5: RFC 1321 A.5 "message digest"',
|
||||
md5('message digest'), 'f96b697d7cb7938d525a2f31aaf161d0');
|
||||
expect('md5: RFC 1321 A.5 a-z',
|
||||
md5('abcdefghijklmnopqrstuvwxyz'), 'c3fcd3d76192e4007dfb496cca67e13b');
|
||||
expect('md5: RFC 1321 A.5 alphanumeric',
|
||||
md5('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'),
|
||||
'd174ab98d277d9f5a5611c2c9f419d9f');
|
||||
expect('md5: RFC 1321 A.5 eighty digits',
|
||||
md5('12345678901234567890123456789012345678901234567890123456789012345678901234567890'),
|
||||
'57edf4a22be3c955ac49da2e2107b67a');
|
||||
expect('md5: CJK hashes as UTF-8', md5(MD5_CJK), 'a7bac2239fcdcb3a067903d8077c4a07');
|
||||
expect('md5: an astral char is one 4-byte sequence',
|
||||
md5(MD5_EMOJI), '2a02eac39d716a70ecf37579185927b6');
|
||||
expect('md5: CRLF is hashed as-is', md5(chr(0x0d) + chr(0x0a)),
|
||||
'81051bcc2cf1bedf378224b0a93e2877');
|
||||
/* The grouphash shape itself: this is md5 of a subscription URL, i.e. what
|
||||
* update_subscriptions.uc writes and what node.js has to reproduce after
|
||||
* stripping the "#label" fragment. */
|
||||
expect('md5: the grouphash shape',
|
||||
md5('https://example.com/sub?token=abc'), '89167ef6961b44a5ba6e799f7cf8cd30');
|
||||
|
||||
printf('%d checks, %d failures\n', checks, failures);
|
||||
exit(failures ? 1 : 0);
|
||||
@@ -0,0 +1,204 @@
|
||||
#!/bin/sh
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# The subscription updater has to actually run.
|
||||
#
|
||||
# update_subscriptions.uc used to read subscription_urls and filter_keywords
|
||||
# off access_control.subscription, while the Loader puts them on access_control
|
||||
# itself. Both came back [], so `if (!isEmpty(subscription_urls)) call(main)`
|
||||
# was false: the script exited 0 having logged nothing, and both the LuCI
|
||||
# "Update nodes from subscriptions" button and the cron entry were silent
|
||||
# no-ops. Nothing caught it because no test had ever executed main() - the
|
||||
# repository test calls apply_nodes() directly, and the model test asserted the
|
||||
# *correct* shape, which only made the consumer's wrong read look fine.
|
||||
#
|
||||
# So this test drives the real script end to end and asserts the one property
|
||||
# that was missing: given a configured subscription URL, the updater must
|
||||
# attempt an update and say something about the outcome. The URL points at a
|
||||
# closed port, so the attempt fails fast and main() returns before the
|
||||
# `/etc/init.d/homeproxy reload` at its end - the device is not touched.
|
||||
#
|
||||
# Usage: sh tests/ucode/test_subscription_updater_runs.sh <repo-root> [workdir]
|
||||
|
||||
set -u
|
||||
|
||||
ROOT="$(cd "${1:-$(dirname "$0")/../..}" && pwd)"
|
||||
WORK="${2:-/tmp/hp-updater-test}"
|
||||
|
||||
if ! command -v ucode > "/dev/null" 2>&1; then
|
||||
echo "NOT RUN: ucode is not on PATH."
|
||||
exit 2
|
||||
fi
|
||||
|
||||
rm -rf "$WORK"
|
||||
mkdir -p "$WORK/scripts" "$WORK/cfg" "$WORK/run"
|
||||
cp -R "$ROOT/root/etc/homeproxy/scripts/." "$WORK/scripts/"
|
||||
|
||||
# Shadow `touch` so the test can see that a run refreshes its lock: the lock's
|
||||
# mtime is the only thing that separates a live run from one a kill left
|
||||
# behind, and the fetch loop (one wget per URL, ten seconds each) is where a
|
||||
# run spends its time. The stub records the call and forwards to the real one.
|
||||
REAL_TOUCH="$(command -v touch)"
|
||||
mkdir -p "$WORK/bin"
|
||||
cat > "$WORK/bin/touch" <<EOF
|
||||
#!/bin/sh
|
||||
printf '%s\n' "\$*" >> "$WORK/touch.log"
|
||||
exec "$REAL_TOUCH" "\$@"
|
||||
EOF
|
||||
chmod +x "$WORK/bin/touch"
|
||||
PATH="$WORK/bin:$PATH"
|
||||
export PATH
|
||||
|
||||
# Redirect the package's runtime dir into the sandbox so the test does not
|
||||
# append to the device's live /var/run/homeproxy/homeproxy.log, and point the
|
||||
# configuration directory at the sandbox. The updater reads the config
|
||||
# *inside* the lock now (A4), and it takes the directory from homeproxy.uc's
|
||||
# UCICONFIG_DIR, so both rewrites land in this one file.
|
||||
sed -e "s#^export const RUN_DIR = '/var/run/homeproxy';#export const RUN_DIR = '$WORK/run';#" \
|
||||
-e "s#^export const UCICONFIG_DIR = '/etc/config';#export const UCICONFIG_DIR = '$WORK/cfg';#" \
|
||||
"$ROOT/root/etc/homeproxy/scripts/homeproxy.uc" > "$WORK/scripts/homeproxy.uc"
|
||||
|
||||
# An unpatched copy would read - and on a real device write - the live
|
||||
# /etc/config/homeproxy. Fail here instead of silently testing the wrong file.
|
||||
if ! grep -q "^export const UCICONFIG_DIR = '$WORK/cfg';" "$WORK/scripts/homeproxy.uc"; then
|
||||
echo "FAIL: could not point the updater at the sandbox config (anchor moved)"
|
||||
rm -rf "$WORK"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Point the updater's Loader at the sandbox config. Portable sed: write to a
|
||||
# temp file and rename, so this works under both busybox and BSD sed.
|
||||
#
|
||||
# The updater resolves the directory through homeproxy.uc's UCICONFIG_DIR (see
|
||||
# the rewrite above), so this step only needs to confirm the staged copy is the
|
||||
# one that was rewritten - it no longer contains a literal config path.
|
||||
if ! grep -q "^export const UCICONFIG_DIR = '$WORK/cfg';" "$WORK/scripts/homeproxy.uc"; then
|
||||
echo "FAIL: the staged homeproxy.uc does not point at the sandbox config"
|
||||
rm -rf "$WORK"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat > "$WORK/cfg/homeproxy" <<-EOF
|
||||
config homeproxy 'config'
|
||||
option routing_mode 'proxy'
|
||||
option main_node 'nil'
|
||||
|
||||
config homeproxy 'subscription'
|
||||
list subscription_url 'https://127.0.0.1:1/never'
|
||||
option filter_nodes 'disabled'
|
||||
option auto_update '0'
|
||||
EOF
|
||||
|
||||
LOGFILE="$WORK/run/homeproxy.log"
|
||||
|
||||
FAILED=0
|
||||
if ( cd "$WORK/scripts" && ucode -L "$WORK/scripts" update_subscriptions.uc ) > "$WORK/stdout" 2>&1; then
|
||||
:
|
||||
else
|
||||
echo "FAIL: update_subscriptions.uc exited non-zero"
|
||||
cat "$WORK/stdout"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
# The property under test: a configured URL means the updater must try. Before
|
||||
# the fix the log stayed empty because main() was never reached.
|
||||
if [ -s "$LOGFILE" ]; then
|
||||
echo "PASS: the updater attempted an update and logged the outcome"
|
||||
sed 's/^/ /' "$LOGFILE"
|
||||
else
|
||||
echo "FAIL: the updater logged nothing - main() was not reached, so the"
|
||||
echo " subscription URL was read from the wrong level again"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
# And the log must name the failure rather than claim success.
|
||||
if grep -q "Successfully updated subscriptions" "$LOGFILE" 2>/dev/null; then
|
||||
echo "FAIL: the updater reported success against an unreachable subscription"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
# The run has to keep its own lock fresh for as long as it works: nothing
|
||||
# refreshed the mtime while the fetch was in progress, so a run slower than
|
||||
# LOCK_STALE could have its lock reclaimed as stale and then race the second
|
||||
# run through the same read-modify-write.
|
||||
if grep -q "update_subscriptions.lock" "$WORK/touch.log" 2>/dev/null; then
|
||||
echo "PASS: the updater refreshes its lock while it works"
|
||||
else
|
||||
echo "FAIL: the lock's mtime is never refreshed - a run longer than the stale"
|
||||
echo " window can have its lock broken underneath it"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
# --- the lock ------------------------------------------------------------
|
||||
# The cron entry and the LuCI button can both start this script, and an update
|
||||
# is a read-modify-write of the whole configuration, so two runs interleaving
|
||||
# them is last-writer-wins. A fresh lock must make the second run stand down...
|
||||
LOCKDIR="$WORK/run/update_subscriptions.lock"
|
||||
rm -f "$LOGFILE"
|
||||
mkdir -p "$LOCKDIR"
|
||||
|
||||
# A4: the lock is now taken *before* the configuration is read, so a run that
|
||||
# stands down must not have touched the config at all - and in particular must
|
||||
# not have written the recovery snapshot the failure path would restore.
|
||||
cp "$WORK/cfg/homeproxy" "$WORK/cfg.before-standdown"
|
||||
|
||||
if ( cd "$WORK/scripts" && ucode -L "$WORK/scripts" update_subscriptions.uc ) > "$WORK/stdout2" 2>&1; then
|
||||
:
|
||||
else
|
||||
echo "FAIL: the updater exited non-zero while another run held the lock"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
if grep -q "already running" "$LOGFILE" 2>/dev/null; then
|
||||
echo "PASS: a held lock makes the updater stand down"
|
||||
else
|
||||
echo "FAIL: the updater ignored an existing lock"
|
||||
sed 's/^/ /' "$LOGFILE"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
if cmp -s "$WORK/cfg.before-standdown" "$WORK/cfg/homeproxy"; then
|
||||
echo "PASS: a run that lost the lock left the configuration untouched"
|
||||
else
|
||||
echo "FAIL: a run that lost the lock still rewrote the configuration"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
if [ -e "$WORK/cfg/homeproxy.hp-restore" ]; then
|
||||
echo "FAIL: a run that lost the lock wrote a recovery snapshot"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
# ...and a lock left behind by a killed process must not block every future
|
||||
# update, so one older than the stale window is broken.
|
||||
rm -f "$LOGFILE"
|
||||
rm -rf "$LOCKDIR"
|
||||
mkdir -p "$LOCKDIR"
|
||||
touch -t 202001010000 "$LOCKDIR"
|
||||
if ( cd "$WORK/scripts" && ucode -L "$WORK/scripts" update_subscriptions.uc ) > "$WORK/stdout3" 2>&1; then
|
||||
:
|
||||
else
|
||||
echo "FAIL: the updater exited non-zero on a stale lock"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
if grep -q "Breaking a stale update lock" "$LOGFILE" 2>/dev/null; then
|
||||
echo "PASS: a stale lock is broken rather than blocking forever"
|
||||
else
|
||||
echo "FAIL: a stale lock was not broken - every future update would be skipped"
|
||||
sed 's/^/ /' "$LOGFILE"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
if [ -d "$LOCKDIR" ]; then
|
||||
echo "FAIL: the lock was left behind after the run"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
rm -rf "$WORK"
|
||||
|
||||
if [ "$FAILED" != 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/ucode
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Direct unit tests for the TLS / transport object builders. Before
|
||||
* this commit every TLS / transport code path went through the
|
||||
* generator fixtures or the golden JSON snapshot - both end-to-end,
|
||||
* both slow, and neither pointed a finger at the builder that
|
||||
* actually broke. This file imports the builders directly and runs
|
||||
* the shape checks that the generator was previously trusted to
|
||||
* enforce, including the sing-box 1.14 constraints that the builders
|
||||
* themselves carry (cert_path whitelist, no insecure on server-side,
|
||||
* reality private_key only on server-side, etc.).
|
||||
*
|
||||
* load_tls / load_transport are also imported but only exercised via
|
||||
* the simplest possible get()-stub - they are field-flattening
|
||||
* wrappers whose only real correctness criterion is "the right UCI
|
||||
* name maps to the right builder field", which the snapshot
|
||||
* regression already covers transitively.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { buildTLSObject, buildTransportObject, validateHomeProxyPath, validateCertificatePath, HP_DIR } from 'homeproxy';
|
||||
|
||||
let failures = 0,
|
||||
checks = 0;
|
||||
|
||||
function expect(name, actual, want) {
|
||||
checks++;
|
||||
if (sprintf('%J', actual) !== sprintf('%J', want)) {
|
||||
printf('FAIL %s: expected %J, got %J\n', name, want, actual);
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- buildTLSObject ---------------------------------------------------- */
|
||||
|
||||
/* 1. disabled -> null. The generator treats null as "TLS off"; if the
|
||||
* builder ever started emitting an object when tls.enabled !== '1',
|
||||
* sing-box would happily accept it and silently enable TLS. */
|
||||
expect('disabled→null', buildTLSObject({ enabled: '0', server_name: 'example.com' }, false), null);
|
||||
expect('null tls→null', buildTLSObject(null, false), null);
|
||||
|
||||
/* 2. client-side bare minimum: enabled + server_name round-trip. */
|
||||
expect('client.enabled', buildTLSObject({ enabled: '1', server_name: 's.example.com' }, false).enabled, true);
|
||||
expect('client.server_name', buildTLSObject({ enabled: '1', server_name: 's.example.com' }, false).server_name, 's.example.com');
|
||||
expect('client.insecure (null on raw)', buildTLSObject({ enabled: '1', server_name: 's' }, false).insecure, null);
|
||||
|
||||
/* 3. insecure=true only meaningful on the client side; the server
|
||||
* builder always emits null for `insecure` because TLS servers
|
||||
* do not skip verification. A previous version emitted
|
||||
* insecure=true on server, which made sing-box refuse to start. */
|
||||
const clientInsecure = buildTLSObject({ enabled: '1', server_name: 's', insecure: '1' }, false);
|
||||
expect('client.insecure=1 parsed', clientInsecure.insecure, true);
|
||||
const serverInsecure = buildTLSObject({ enabled: '1', server_name: 's', insecure: '1' }, true);
|
||||
expect('server.insecure forced null', serverInsecure.insecure, null);
|
||||
|
||||
/* 4. cert_path whitelist - the validator accepts /etc/homeproxy/... and
|
||||
* /tmp/homeproxy_..., and rejects everything else (including
|
||||
* /etc/passwd and relative paths). This is the security patch
|
||||
* that backs the §4 path-whitelist entry; a regression here would
|
||||
* let UCI drive sing-box into reading arbitrary files as root. */
|
||||
expect('cert.path /etc/homeproxy', validateHomeProxyPath('/etc/homeproxy/certs/server_publickey.pem'), true);
|
||||
expect('cert.path /tmp/homeproxy_', validateHomeProxyPath('/tmp/homeproxy_test/foo.pem'), true);
|
||||
expect('cert.path /etc/passwd', validateHomeProxyPath('/etc/passwd'), false);
|
||||
expect('cert.path /var/run/cron', validateHomeProxyPath('/var/run/cron'), false);
|
||||
expect('cert.path relative', validateHomeProxyPath('relative/path.pem'), false);
|
||||
expect('cert.path null', validateHomeProxyPath(null), false);
|
||||
expect('cert.path empty', validateHomeProxyPath(''), false);
|
||||
|
||||
const clientCert = buildTLSObject({ enabled: '1', server_name: 's', cert_path: '/etc/homeproxy/certs/foo.pem' }, false);
|
||||
expect('client.cert_path accepted', clientCert.certificate_path, '/etc/homeproxy/certs/foo.pem');
|
||||
const clientCertBad = buildTLSObject({ enabled: '1', server_name: 's', cert_path: '/etc/passwd' }, false);
|
||||
expect('client.cert_path rejected→null', clientCertBad.certificate_path, null);
|
||||
|
||||
/* 4b. Certificate paths are a *different* policy from rule-set paths.
|
||||
* A TLS certificate normally lives under /etc/ssl/ or under the ACME
|
||||
* state in /etc/acme/, and both sing-box jails mount those directories
|
||||
* (runtime/service.sh). validateHomeProxyPath() only knows
|
||||
* /etc/homeproxy/ and /tmp/homeproxy_, so a certificate the UI offered
|
||||
* from /etc/ssl/ was accepted there and then silently dropped on the
|
||||
* way into sing-box - certificate_path became null and the TLS listener
|
||||
* failed with nothing pointing at the path. CERT_PATH_ROOTS is the
|
||||
* backend half of the policy the frontend validator mirrors (guard 29). */
|
||||
expect('cert.paths /etc/ssl accepted', validateCertificatePath('/etc/ssl/certs/srv.pem'), true);
|
||||
expect('cert.paths /etc/acme accepted', validateCertificatePath('/etc/acme/example.com/cert.pem'), true);
|
||||
expect('cert.paths /etc/homeproxy/certs accepted', validateCertificatePath('/etc/homeproxy/certs/srv.pem'), true);
|
||||
expect('cert.paths /tmp/homeproxy_ rejected',
|
||||
validateCertificatePath('/tmp/homeproxy_test/foo.pem'), false);
|
||||
expect('cert.paths /etc/passwd rejected', validateCertificatePath('/etc/passwd'), false);
|
||||
expect('cert.paths traversal rejected', validateCertificatePath('/etc/ssl/../shadow'), false);
|
||||
expect('cert.paths bare root rejected', validateCertificatePath('/etc/ssl/'), false);
|
||||
expect('cert.paths relative rejected', validateCertificatePath('etc/ssl/srv.pem'), false);
|
||||
expect('cert.paths null rejected', validateCertificatePath(null), false);
|
||||
expect('cert.paths empty rejected', validateCertificatePath(''), false);
|
||||
|
||||
/* The two policies must stay disjoint in the direction that matters: a
|
||||
* rule-set path may not reach into /etc/ssl/, and a certificate does not need
|
||||
* /tmp/homeproxy_ (that root is for upload staging). */
|
||||
expect('rule-set path still rejects /etc/ssl', validateHomeProxyPath('/etc/ssl/certs/srv.pem'), false);
|
||||
|
||||
const sslCert = buildTLSObject({ enabled: '1', server_name: 's', cert_path: '/etc/ssl/certs/srv.pem' }, false);
|
||||
expect('client.cert_path /etc/ssl kept', sslCert.certificate_path, '/etc/ssl/certs/srv.pem');
|
||||
const sslServerKey = buildTLSObject({ enabled: '1', server_name: 's', cert_path: '/etc/acme/c.pem' }, true,
|
||||
{ tls_key_path: '/etc/ssl/private/k.pem' });
|
||||
expect('server.key_path /etc/ssl kept', sslServerKey.key_path, '/etc/ssl/private/k.pem');
|
||||
const traversalCert = buildTLSObject({ enabled: '1', server_name: 's', cert_path: '/etc/ssl/../shadow' }, false);
|
||||
expect('client.cert_path traversal→null', traversalCert.certificate_path, null);
|
||||
|
||||
/* 4c. The client ECH config path is a file sing-box opens as root too, and it
|
||||
* used to reach the config unvalidated. */
|
||||
const echOk = buildTLSObject(
|
||||
{ enabled: '1', server_name: 's', ech: { enabled: '1', config_path: '/etc/homeproxy/certs/ech.pem' } }, false);
|
||||
expect('client.ech.config_path accepted', echOk.ech.config_path, '/etc/homeproxy/certs/ech.pem');
|
||||
const echBad = buildTLSObject(
|
||||
{ enabled: '1', server_name: 's', ech: { enabled: '1', config_path: '/etc/passwd' } }, false);
|
||||
expect('client.ech.config_path rejected→null', echBad.ech.config_path, null);
|
||||
|
||||
/* 5. utls: client-only. The server builder must never emit utls.
|
||||
* Servers using uTLS would silently break sing-box. */
|
||||
const clientUtls = buildTLSObject({ enabled: '1', server_name: 's', utls: { fingerprint: 'chrome' } }, false);
|
||||
expect('client.utls.fingerprint', clientUtls.utls.fingerprint, 'chrome');
|
||||
const serverUtls = buildTLSObject({ enabled: '1', server_name: 's', utls: { fingerprint: 'chrome' } }, true);
|
||||
expect('server.utls forced null', serverUtls.utls, null);
|
||||
|
||||
/* 6. reality: client gets public_key only; server gets private_key.
|
||||
* Mixing them up would let a malicious UCI value plant the wrong
|
||||
* key on the wrong side. */
|
||||
const clientReality = buildTLSObject({
|
||||
enabled: '1', server_name: 's',
|
||||
reality: { enabled: '1', public_key: 'PUB', short_id: 'abc' }
|
||||
}, false);
|
||||
expect('client.reality.public_key', clientReality.reality.public_key, 'PUB');
|
||||
expect('client.reality.private_key', clientReality.reality.private_key, null);
|
||||
|
||||
const serverReality = buildTLSObject({
|
||||
enabled: '1', server_name: 's',
|
||||
reality: { enabled: '1', public_key: 'PUB', short_id: 'abc' }
|
||||
}, true, { tls_reality_private_key: 'PRIV', tls_reality_server_addr: 'dest.example.com', tls_reality_server_port: '443' });
|
||||
expect('server.reality.short_id', serverReality.reality.short_id, 'abc');
|
||||
expect('server.reality.private_key', serverReality.reality.private_key, 'PRIV');
|
||||
expect('server.reality.public_key', serverReality.reality.public_key, null);
|
||||
|
||||
/* 7. ECH: client uses config/config_path; server uses key. */
|
||||
const clientEch = buildTLSObject({ enabled: '1', server_name: 's',
|
||||
ech: { enabled: '1', config: 'AAAA', config_path: '/etc/homeproxy/certs/client_ech_conf.pem' } }, false);
|
||||
expect('client.ech.config', clientEch.ech.config, 'AAAA');
|
||||
|
||||
const serverEch = buildTLSObject({ enabled: '1', server_name: 's' }, true, { tls_ech_key: 'AAAA\nBBBB' });
|
||||
expect('server.ech key parsed', serverEch.ech.key[0], 'AAAA');
|
||||
expect('server.ech.key[1]', serverEch.ech.key[1], 'BBBB');
|
||||
|
||||
/* --- buildTransportObject ---------------------------------------------- */
|
||||
|
||||
/* 1. empty transport -> null. */
|
||||
expect('transport.empty→null', buildTransportObject({}, false), null);
|
||||
expect('transport.no type→null', buildTransportObject({ type: null }, false), null);
|
||||
expect('transport.null→null', buildTransportObject(null, false), null);
|
||||
|
||||
/* 2. ws transport round-trip. */
|
||||
const ws = buildTransportObject({
|
||||
type: 'ws', host: 'h.example.com', path: '/ws?ed=2048',
|
||||
headers: { Host: 'h.example.com' }
|
||||
}, false);
|
||||
expect('ws.type', ws.type, 'ws');
|
||||
expect('ws.host', ws.host, 'h.example.com');
|
||||
expect('ws.path', ws.path, '/ws?ed=2048');
|
||||
expect('ws.headers', ws.headers.Host, 'h.example.com');
|
||||
|
||||
/* 3. grpc transport. The permit_without_stream flag is client-only;
|
||||
* the server builder must drop it because sing-box rejects it on
|
||||
* a server inbound (sing-box 1.14 listed it as a known bug). */
|
||||
const clientGrpc = buildTransportObject({
|
||||
type: 'grpc', service_name: 'svc', permit_without_stream: '1'
|
||||
}, false);
|
||||
expect('client.grpc.permit', clientGrpc.permit_without_stream, true);
|
||||
const serverGrpc = buildTransportObject({
|
||||
type: 'grpc', service_name: 'svc', permit_without_stream: '1'
|
||||
}, true);
|
||||
expect('server.grpc.permit forced null', serverGrpc.permit_without_stream, null);
|
||||
|
||||
/* 4. httpupgrade transport. */
|
||||
const hu = buildTransportObject({ type: 'httpupgrade', host: 'h.example.com', path: '/' }, false);
|
||||
expect('httpupgrade.type', hu.type, 'httpupgrade');
|
||||
expect('httpupgrade.host', hu.host, 'h.example.com');
|
||||
|
||||
/* --- summary ----------------------------------------------------------- */
|
||||
|
||||
if (failures > 0)
|
||||
printf('FAIL: %d/%d checks failed\n', failures, checks);
|
||||
else
|
||||
printf('PASS: %d checks\n', checks);
|
||||
|
||||
exit(failures > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,99 @@
|
||||
#!/bin/sh
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# ucode grammar canary.
|
||||
#
|
||||
# The package has to parse on the ucode that ImmortalWrt/OpenWrt snapshots
|
||||
# ship (2026.01.16~85922056 at the time of writing), and that ucode is
|
||||
# STRICTER than current upstream HEAD:
|
||||
#
|
||||
# * `export function name() { ... }` must be terminated by ';'
|
||||
# (upstream relaxed this in b885dd0f, whose own test suite uses the
|
||||
# semicolon-free form; openwrt/openwrt@main now pins that revision)
|
||||
# * object destructuring (`const { a } = x;`) is rejected
|
||||
#
|
||||
# A toolchain built from ucode's default branch therefore accepts code that
|
||||
# no router can parse, and the whole suite stays green while the package is
|
||||
# dead on arrival. That is exactly how the A/B refactor shipped five modules
|
||||
# (config/loader.uc, subscription/{filter,decoder,fetcher,repository}.uc) that
|
||||
# failed to compile on a target, plus an update_subscriptions.uc that used
|
||||
# destructuring.
|
||||
#
|
||||
# This check pins the *dialect* rather than a revision: it asserts that the
|
||||
# ucode under test still rejects those two constructs, so a permissive
|
||||
# toolchain fails here instead of hiding a target regression. It also asserts
|
||||
# that the constructs the package legitimately relies on still compile, so
|
||||
# pinning ucode to something too old fails here too.
|
||||
#
|
||||
# Set HP_ALLOW_PERMISSIVE_UCODE=1 to demote the "must be rejected" failures to
|
||||
# warnings when deliberately probing a newer ucode.
|
||||
#
|
||||
# Usage: sh tests/ucode/test_ucode_grammar.sh
|
||||
|
||||
WORK="$(mktemp -d "${TMPDIR:-/tmp}/hp-ucode-grammar.XXXXXX")" || exit 1
|
||||
trap 'rm -rf "$WORK"' EXIT INT TERM
|
||||
|
||||
if ! command -v ucode > "/dev/null" 2>&1; then
|
||||
echo "NOT RUN: ucode is not on PATH"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
FAILED=0
|
||||
ALLOW_PERMISSIVE="${HP_ALLOW_PERMISSIVE_UCODE:-0}"
|
||||
|
||||
# compile <name> <source...> : write a module and try to load it. `export`
|
||||
# is only legal in a module, so the probe must go through `import` rather than
|
||||
# `ucode -c` on the file directly.
|
||||
compiles() {
|
||||
name="$1"
|
||||
shift
|
||||
file="$WORK/$name.uc"
|
||||
printf '%s\n' "$*" > "$file"
|
||||
ucode -e "import * as m from \"$file\";" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# --- constructs the package relies on: these MUST compile -----------------
|
||||
|
||||
accept() {
|
||||
name="$1"
|
||||
src="$2"
|
||||
if compiles "$name" "$src"; then
|
||||
echo "PASS: $name compiles"
|
||||
else
|
||||
echo "FAIL: $name does not compile (toolchain too old or broken)"
|
||||
FAILED=1
|
||||
fi
|
||||
}
|
||||
|
||||
# --- constructs the target rejects: these MUST fail ----------------------
|
||||
|
||||
reject() {
|
||||
name="$1"
|
||||
src="$2"
|
||||
if compiles "$name" "$src"; then
|
||||
if [ "$ALLOW_PERMISSIVE" = "1" ]; then
|
||||
echo "WARN: $name compiles (permissive ucode allowed by HP_ALLOW_PERMISSIVE_UCODE=1)"
|
||||
else
|
||||
echo "FAIL: $name compiles, but the target ucode rejects it"
|
||||
echo " the toolchain is more permissive than ImmortalWrt/OpenWrt 2026.01.16"
|
||||
echo " (pin tests/toolchain/build-ucode-*.sh UCODE_REV to a strict revision)"
|
||||
FAILED=1
|
||||
fi
|
||||
else
|
||||
echo "PASS: $name rejected by ucode (matches the target)"
|
||||
fi
|
||||
}
|
||||
|
||||
accept exported_function_strict 'export function f() { return 1; };'
|
||||
accept plain_function 'function g() { return 1; }'
|
||||
accept const_export 'export const c = 1;'
|
||||
accept computed_key_object "export const t = { ['a']: 1 };"
|
||||
accept optional_chaining_and_nullish 'export function g(o) { return o?.a ?? 1; };'
|
||||
accept object_spread 'export function h() { return { ...{ a: 1 } }; };'
|
||||
accept template_literal 'export function i(v) { return `x${v}`; };'
|
||||
|
||||
reject exported_function_without_semicolon 'export function f() { return 1; }'
|
||||
reject object_destructuring 'export function h(o) { const { a } = o; return a; };'
|
||||
reject array_destructuring 'export function i(a) { const [x] = a; return x; };'
|
||||
|
||||
exit $FAILED
|
||||
Reference in new issue
Block a user