mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-28 10:42:51 +08:00
300 lines
16 KiB
Ucode
300 lines
16 KiB
Ucode
#!/usr/bin/ucode
|
|
/*
|
|
* SPDX-License-Identifier: GPL-2.0-only
|
|
*
|
|
* Copyright (C) 2025 ImmortalWrt.org
|
|
*
|
|
* Regression tests for homeproxy-pro.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, isValidCIDR, isValidPEM, redactReason, redactUrl, ruleSetFormatFromBytes,
|
|
ruleSetFormatFromPath, RULESET_PROBE_BYTES, shellQuote, wGETVerbose } from 'homeproxy-pro';
|
|
|
|
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 the fetcher an option the fetcher does not
|
|
* have.
|
|
*
|
|
* This guard already existed once, for --max-filesize, and then failed to catch
|
|
* the same class again: the fetch shelled out to `wget` with GNU-only options
|
|
* (-nv --user-agent --timeout=), and `wget` is whichever implementation the
|
|
* firmware's buildroot compiled. On a busybox-wget router every one of those is
|
|
* rejected with "unrecognized option" before a request is made, so every
|
|
* subscription fetch, every resource update and the connectivity check failed -
|
|
* while this test ran against GNU wget on CI and on the maintainer's own
|
|
* device, and stayed green.
|
|
*
|
|
* So the guard now runs against tests/fixtures/uclient-fetch-stub, which
|
|
* accepts exactly the option list read off the applet on a real device and
|
|
* rejects everything else the way the applet does. The accepted set is PINNED
|
|
* rather than inherited from the host, which is the part that was missing.
|
|
* (On a target, where /bin/uclient-fetch really exists, fetchBinary() returns
|
|
* it and these same assertions run against the real applet - so an option
|
|
* nobody verified is caught both off-target and on it.)
|
|
*
|
|
* 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 fetch = wGETVerbose('http://127.0.0.1:1/never');
|
|
expect('fetch.not-a-usage-error',
|
|
match(fetch.error || '', /unrecognized option|invalid option|Usage:/) == null, true);
|
|
expect('fetch.reports-a-reason', length(fetch.error || '') > 0, true);
|
|
expect('fetch.no-content-on-failure', fetch.content, null);
|
|
|
|
/* A 200 whose body is binary. executeCommand() nulls stdout for binary
|
|
* content, and the "no content but stderr said something" branch then used to
|
|
* report it as `fetch failed: … Download completed (34185 bytes)` - a message
|
|
* that contradicts itself and sends the user after a network problem they do
|
|
* not have. Measured on a device against a real .srs. */
|
|
const binfetch = wGETVerbose('http://127.0.0.1:1/HP_T_STUB_BINARY');
|
|
expect('fetch.binary-is-not-a-failure',
|
|
match(binfetch.error || '', /fetch failed/) == null, true, binfetch.error);
|
|
expect('fetch.binary-says-so',
|
|
match(binfetch.error || '', /binary/) != null, true, binfetch.error);
|
|
expect('fetch.binary-has-no-content', binfetch.content, null);
|
|
|
|
/* Review H3: the fetcher announces the requested URL on stderr before it reports
|
|
* anything else, query string and all, so this is what would reach the log.
|
|
*
|
|
* The old version of this test pointed at a *connection* failure, where the URL
|
|
* happens not to appear - which meant it proved only that the quiet path stayed
|
|
* quiet, never that redaction ran. The stub announces the URL the way the real
|
|
* applet does, so this now exercises redactReason() through the whole
|
|
* wGETVerbose() path. The redactReason() block below still covers redaction on
|
|
* its own, against shapes no stub can be asked to produce. */
|
|
const tokfetch = wGETVerbose('http://127.0.0.1:1/?token=secret');
|
|
expect('fetch.token-not-in-error',
|
|
match(tokfetch.error || '', /token=secret/) == null, true);
|
|
/* Redaction must not swallow the diagnostic: the target host still has to be
|
|
* readable, or the message says nothing about what was being fetched. */
|
|
expect('fetch.redacted-still-names-the-target',
|
|
match(tokfetch.error || '', /127\.0\.0\.1/) != null, true, tokfetch.error);
|
|
|
|
/* The HTTP-error shape is the one that leaks - "Downloading '<URL>'" followed
|
|
* by "HTTP error 404" - and it is the common case for a subscription URL that
|
|
* has expired. */
|
|
const httpfetch = wGETVerbose('http://127.0.0.1:1/HP_T_STUB_HTTP_ERROR?token=secret');
|
|
expect('fetch.http-error-token-not-in-error',
|
|
match(httpfetch.error || '', /token=secret/) == null, true);
|
|
expect('fetch.http-error-keeps-the-status',
|
|
match(httpfetch.error || '', /HTTP error/) != null, true, httpfetch.error);
|
|
|
|
/* redactReason(): central redaction that protects every wGETVerbose caller.
|
|
* Tested in isolation so the assertion does not depend on any fetcher being
|
|
* present - this runs anywhere ucode runs. */
|
|
{
|
|
/* The shape uclient-fetch actually emits, copied from a device run:
|
|
*
|
|
* Downloading 'https://user:tok@host.example.com/path?q=token=secret'
|
|
* HTTP error 404
|
|
*
|
|
* It announces the target BEFORE it says anything else, which is why
|
|
* wGETVerbose does not pass -q - and it is therefore the shape that has
|
|
* to be redacted. The URL must lose its query string and its userinfo,
|
|
* and the host has to survive, or the message says nothing about what was
|
|
* being fetched. */
|
|
const r1 = redactReason("Downloading 'https://user:tok@host.example.com/path?q=token=secret' HTTP error 404");
|
|
expect('redactReason.query',
|
|
match(r1, /\?\*\*\*/) != null, true, r1);
|
|
expect('redactReason.userinfo',
|
|
match(r1, /user:tok/) == null, true, r1);
|
|
expect('redactReason.host-preserved',
|
|
match(r1, /host\.example\.com/) != null, true, r1);
|
|
expect('redactReason.reason-preserved',
|
|
match(r1, /HTTP error 404/) != null, true, r1);
|
|
|
|
/* 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('Failed to send request: Operation not permitted'),
|
|
'Failed to send request: Operation not permitted');
|
|
|
|
/* 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("Downloading 'https://host:80080/path?q=token=secret' HTTP error 500");
|
|
expect('redactReason.port-survives',
|
|
match(r3, /host:80080/) != null, true);
|
|
expect('redactReason.port-query-redacted',
|
|
match(r3, /token=secret/) == null, true);
|
|
}
|
|
|
|
/* isValidCIDR(): reject injection through the /boundary. Previously the
|
|
* prefix was checked with `int(prefix) < 0 || int(prefix) > 32`, which
|
|
* let `int('24;}')` parse as 24 (strtoll stops at the first non-digit),
|
|
* so a poisoned china_ip4.txt line like `1.2.3.4/24;}` sailed through
|
|
* and ended up verbatim in the fw4 ruleset. These cases pin both the
|
|
* unanchored-injection reject and the basic shape coverage. */
|
|
expect('cidr4.basic', isValidCIDR('1.2.3.4', 4), true);
|
|
expect('cidr4.with-prefix', isValidCIDR('1.2.3.4/24', 4), true);
|
|
expect('cidr4.boundary-0', isValidCIDR('0.0.0.0/0', 4), true);
|
|
expect('cidr4.boundary-32', isValidCIDR('255.255.255.255/32', 4), true);
|
|
expect('cidr4.octet-overflow', isValidCIDR('1.2.3.999', 4), false);
|
|
expect('cidr4.empty', isValidCIDR('', 4), false);
|
|
expect('cidr4.whitespace', isValidCIDR(' ', 4), false);
|
|
expect('cidr4.leading-space', isValidCIDR(' 1.2.3.4', 4), true);
|
|
expect('cidr4.bad-family', isValidCIDR('1.2.3.4', 99), false);
|
|
/* Injection vectors - the actual bug shape. */
|
|
expect('cidr4.prefix-injection', isValidCIDR('1.2.3.4/24;}', 4), false);
|
|
expect('cidr4.prefix-comment', isValidCIDR('1.2.3.4/24 #evil', 4), false);
|
|
expect('cidr4.prefix-non-digit', isValidCIDR('1.2.3.4/abc', 4), false);
|
|
expect('cidr4.prefix-overflow', isValidCIDR('1.2.3.4/33', 4), false);
|
|
expect('cidr4.prefix-negative', isValidCIDR('1.2.3.4/-1', 4), false);
|
|
expect('cidr4.prefix-too-many', isValidCIDR('1.2.3.4/12345', 4), false);
|
|
expect('cidr4.two-slashes', isValidCIDR('1.2.3.4/24/32', 4), false);
|
|
expect('cidr4.empty-prefix', isValidCIDR('1.2.3.4/', 4), false);
|
|
expect('cidr4.tail-after-ip', isValidCIDR('1.2.3.4 garbage', 4), false);
|
|
|
|
/* IPv6: same anchor logic. Compressed / uncompressed / invalid shapes. */
|
|
expect('cidr6.basic', isValidCIDR('::1', 6), true);
|
|
expect('cidr6.uncompressed', isValidCIDR('2001:db8::1', 6), true);
|
|
expect('cidr6.full', isValidCIDR('fe80::1/64', 6), true);
|
|
expect('cidr6.boundary-128', isValidCIDR('::1/128', 6), true);
|
|
expect('cidr6.prefix-overflow', isValidCIDR('::1/129', 6), false);
|
|
expect('cidr6.prefix-injection',isValidCIDR('::1/64;}', 6), false);
|
|
expect('cidr6.two-colons', isValidCIDR('1::2::3', 6), false);
|
|
expect('cidr6.empty', isValidCIDR('', 6), false);
|
|
|
|
/* --- the rule-set format probe -------------------------------------------
|
|
*
|
|
* Two pure functions, so the whole decision is testable without a file and
|
|
* without sing-box: ruleSetFormatFromBytes() looks at bytes, and
|
|
* ruleSetFormatFromPath() says what sing-box's extension inference would
|
|
* have decided for a name. The pairing is the point - the generator compares
|
|
* the two (generator/ruleset.uc's resolveFormat) and only speaks when they
|
|
* disagree - so a regression in either half has to show up here.
|
|
*
|
|
* "SRS" is 0x53 0x52 0x53. It is written as a literal because that IS the
|
|
* byte sequence, and the test is about the function answering correctly for
|
|
* the format's own identity, not about how the constant was spelled.
|
|
*
|
|
* Returns are 'binary' | 'source' | null, and the null cases carry most of
|
|
* the weight: the probe must decline to have an opinion rather than guess,
|
|
* because a guess here becomes a `format` declaration that makes sing-box
|
|
* reject the configuration at startup. */
|
|
expect('probe: SRS magic is binary', ruleSetFormatFromBytes('SRS' + 'x', 4), 'binary');
|
|
expect('probe: a three-byte SRS is binary', ruleSetFormatFromBytes('SRS', 3), 'binary');
|
|
expect('probe: a JSON object is source', ruleSetFormatFromBytes('{"version":3,"rules":[]}', 21), 'source');
|
|
/* Leading whitespace is the normal shape of a file written by an editor or a
|
|
* Windows tool, and the decision must not depend on byte 0 being '{'. */
|
|
expect('probe: leading whitespace is still source',
|
|
ruleSetFormatFromBytes('\n\t {"version":3}', 14), 'source');
|
|
expect('probe: a JSON array is source', ruleSetFormatFromBytes('[{"a":1}]', 8), 'source');
|
|
|
|
/* Every null case is a "do not touch the user's field" case. */
|
|
expect('probe: an empty file is no verdict', ruleSetFormatFromBytes('', 0), null);
|
|
expect('probe: a failed read is no verdict', ruleSetFormatFromBytes('', 10), null);
|
|
expect('probe: two bytes are not the magic', ruleSetFormatFromBytes('SR', 2), null);
|
|
expect('probe: a near-miss magic is no verdict', ruleSetFormatFromBytes('SRX', 3), null);
|
|
/* The one that matters most: prose is text and is still not a source
|
|
* rule-set. If "looks like text" were the test, this would answer 'source'
|
|
* and the generated config would name a JSON file that does not exist. */
|
|
expect('probe: printable text is no verdict', ruleSetFormatFromBytes('hello world', 11), null);
|
|
/* What a failed download actually leaves behind under a .srs name. */
|
|
expect('probe: an HTML error page is no verdict',
|
|
ruleSetFormatFromBytes('<!DOCTYPE html><html></html>', 27), null);
|
|
|
|
expect('probe: the read window is a sane size',
|
|
RULESET_PROBE_BYTES >= 8 && RULESET_PROBE_BYTES <= 4096, true);
|
|
|
|
/* What sing-box's extension inference would have decided, expressed in the
|
|
* same two values. null is the case that produces "missing format" rather
|
|
* than a wrong parse, and it is the reason a content probe is worth having. */
|
|
expect('inference: .srs is binary', ruleSetFormatFromPath('/etc/homeproxy-pro/ruleset/example.srs'), 'binary');
|
|
expect('inference: .json is source', ruleSetFormatFromPath('/etc/homeproxy-pro/ruleset/example.json'), 'source');
|
|
expect('inference: no extension has no verdict', ruleSetFormatFromPath('/etc/homeproxy-pro/ruleset/example'), null);
|
|
/* The suffix is what counts, not the first dot in the name. */
|
|
expect('inference: .srs.txt is no verdict', ruleSetFormatFromPath('/etc/homeproxy-pro/ruleset/x.srs.txt'), null);
|
|
expect('inference: the suffix is case-sensitive', ruleSetFormatFromPath('/etc/homeproxy-pro/ruleset/x.SRS'), null);
|
|
/* A {tag} placeholder comes before the suffix, so it must not hide it. */
|
|
expect('inference: a {tag} path keeps its suffix', ruleSetFormatFromPath('/etc/homeproxy-pro/ruleset/{tag}.srs'), 'binary');
|
|
expect('inference: null has no verdict', ruleSetFormatFromPath(null), null);
|
|
expect('inference: an empty path has no verdict', ruleSetFormatFromPath(''), null);
|
|
|
|
/* 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);
|