Files
op-packages/luci-app-homeproxy-pro/tests/ucode/test_homeproxy-pro_utils.uc
T

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);