mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-06 20:14:10 +08:00
⛄ Sync 2026-09-15 17:04:17
This commit is contained in:
1 parent
578dfde655
commit
c224e11cc7
62 files changed
+16332
-19628
No files matched your search
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* 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. Everything else
|
||||
* (isEmpty, decodeBase64Str, parseURL) is a verbatim copy so the parser tests
|
||||
* exercise the same string handling as production. Keep these in sync when
|
||||
* homeproxy.uc changes.
|
||||
*/
|
||||
|
||||
import { urldecode_params } from 'luci.http';
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
/* Pure-ucode stand-in for /sbin/validate_data (host/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;
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
Executable
+87
@@ -0,0 +1,87 @@
|
||||
#!/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
|
||||
|
||||
echo "== ucode syntax check =="
|
||||
for file in "$ROOT"/root/etc/homeproxy/scripts/*.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|*parse_uri.uc) continue ;;
|
||||
esac
|
||||
if ! ucode -c -o "/dev/null" "$file" 2> "/tmp/hp-ucode-syntax.err"; then
|
||||
echo "FAIL: $file"
|
||||
head -8 "/tmp/hp-ucode-syntax.err"
|
||||
FAILED=1
|
||||
fi
|
||||
done
|
||||
for module in homeproxy parse_uri; do
|
||||
if ! ucode -L "$ROOT/root/etc/homeproxy/scripts" -e "import * as m from \"$module\";" 2> "/tmp/hp-ucode-syntax.err"; then
|
||||
echo "FAIL: module $module"
|
||||
head -8 "/tmp/hp-ucode-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 "== firewall template rendering =="
|
||||
sh "$ROOT/tests/ucode/test_firewall_template.sh" "$ROOT" || FAILED=1
|
||||
|
||||
echo "== parse_uri unit tests =="
|
||||
rm -rf "$WORK/parse_uri"
|
||||
mkdir -p "$WORK/parse_uri"
|
||||
cp "$ROOT/root/etc/homeproxy/scripts/parse_uri.uc" "$WORK/parse_uri/"
|
||||
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 test_parse_uri.uc ); then
|
||||
echo "PASS: parse_uri unit tests"
|
||||
else
|
||||
echo "FAIL: parse_uri unit tests"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
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 "== executeCommand() failure-path test =="
|
||||
rm -rf "$WORK/homeproxy_inject"
|
||||
mkdir -p "$WORK/homeproxy_inject"
|
||||
sed 's|const exitcode = system(.*);|die("injected failure");|' \
|
||||
"$ROOT/root/etc/homeproxy/scripts/homeproxy.uc" > "$WORK/homeproxy_inject/homeproxy.uc"
|
||||
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
|
||||
|
||||
exit $FAILED
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/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.
|
||||
#
|
||||
# Usage: sh tests/ucode/test_firewall_template.sh <repo-root>
|
||||
|
||||
ROOT="${1:-.}"
|
||||
ROOT="$(cd "$ROOT" && pwd)"
|
||||
OUT="$(mktemp)"
|
||||
FAILED=0
|
||||
|
||||
if ! utpl -S "$ROOT/root/etc/homeproxy/scripts/firewall_post.ut" > "$OUT" 2>"/tmp/hp-fw4tpl.err"; then
|
||||
echo "FAIL: could not render firewall_post.ut"
|
||||
head -5 "/tmp/hp-fw4tpl.err"
|
||||
rm -f "$OUT"
|
||||
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
|
||||
|
||||
[ "$FAILED" -eq 0 ] && echo "PASS: firewall_post.ut renders homeproxy objects as standalone nft statements"
|
||||
|
||||
rm -f "$OUT"
|
||||
exit $FAILED
|
||||
@@ -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
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
#!/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.
|
||||
#
|
||||
# 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
|
||||
|
||||
run_case() {
|
||||
name="$1"
|
||||
fixture="$2"
|
||||
generator="$3"
|
||||
outfile="$4"
|
||||
dir="$WORK/$name"
|
||||
|
||||
rm -rf "$dir"
|
||||
mkdir -p "$dir/config" "$dir/run" "$dir/scripts" "$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.
|
||||
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
|
||||
sed "s#__RULESET_DIR__#$dir/ruleset#" "$fixture" > "$dir/config/homeproxy"
|
||||
else
|
||||
cp "$fixture" "$dir/config/homeproxy"
|
||||
fi
|
||||
|
||||
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';#" \
|
||||
"$ROOT/root/etc/homeproxy/scripts/homeproxy.uc" > "$dir/scripts/homeproxy.uc"
|
||||
|
||||
sed -e "s#const uci = cursor();#const uci = cursor('$dir/config');#" \
|
||||
"$ROOT/root/etc/homeproxy/scripts/$generator" > "$dir/scripts/$generator"
|
||||
|
||||
if ! ( cd "$dir/scripts" && ucode "$generator" ); then
|
||||
echo "FAIL: $name: $generator exited non-zero"
|
||||
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 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
|
||||
|
||||
run_case custom "$ROOT/tests/fixtures/generators/custom.uci" generate_client.uc sing-box-c.json
|
||||
run_case server "$ROOT/tests/fixtures/generators/server.uci" generate_server.uc sing-box-s.json
|
||||
|
||||
exit $FAILED
|
||||
@@ -0,0 +1,80 @@
|
||||
#!/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, shellQuote } 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);
|
||||
|
||||
/* 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);
|
||||
|
||||
/* 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,170 @@
|
||||
#!/usr/bin/ucode
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Copyright (C) 2025 ImmortalWrt.org
|
||||
*
|
||||
* Unit tests for the share-link parsers in parse_uri.uc. Run through
|
||||
* tests/ucode/run.sh, which stages parse_uri.uc next to the mocked
|
||||
* homeproxy.uc.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { parse_uri } from 'parse_uri';
|
||||
|
||||
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));
|
||||
|
||||
printf('%d checks, %d failures\n', checks, failures);
|
||||
exit(failures === 0 ? 0 : 1);
|
||||
Reference in new issue
Block a user