Files
op-packages/luci-app-substore/tests/p1_fixes_test.lua
T

411 lines
20 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- p1_fixes_test.lua — P1 批次缺陷修复的回归测试
-- 用法:lua5.1 tests/p1_fixes_test.lua
--
-- 覆盖本轮修复(每项都先在修改前用探针复现过,再断言修复后的行为):
-- M1 trojan 密码未做 url_decode
-- M2 vmess classic JSON 忽略 host / path
-- M3 SSR 密码 base64 回退不可达
-- M4 vless/trojan/vmess/hysteria2/tuic/ss 缺 host/port 校验
-- M5 sing-box YAML 嵌套 tls: map 被跳过(含嵌套序列 alpn / utls.fingerprint)
-- M7 通用 JSON 节点路径忽略 type 字段、无协议白名单
-- M8 Surge 段名大小写敏感([PROXY] 不识别)
-- M10 userinfo 按首个 @ 切分(密码含 @ 时错位)
-- M12 core.load 遇到非 table 条目直接崩溃
-- M15 改名替换串里的 % 未转义(吞字符 / 注入 NUL 字节)
-- M20 output_v2ray.supported() 只排除 ssr
-- M21 clashmeta 从不写 flow
-- M22 clashmeta 丢弃 grpc / h2 传输参数
-- 另:Surge/QX 未知协议白名单(H6 的 Surge 侧)、简易 YAML 嵌套序列
package.path = "./root/usr/share/?.lua;" .. package.path
local util = require("substore.util")
local parser = require("substore.parser")
local surge = require("substore.parser_surge")
local node = require("substore.node")
local core = require("substore.core")
local output = require("substore.output")
local output_clash_meta = require("substore.output_clash_meta")
local passed, failed = 0, 0
local function check(name, cond)
if cond then passed = passed + 1; print("PASS " .. name)
else failed = failed + 1; print("FAIL " .. name) end
end
-- 解析单条 URI,返回节点(或 nil)
local function one(uri)
local r = parser.parse(uri)
return r and r.nodes and r.nodes[1] or nil
end
-- ---------- M1:trojan 密码必须 url_decode ----------
-- 未解码时密码会是字面量 "p%40ss%3Aword",认证必然失败。
local t1 = one("trojan://p%40ss%3Aword@1.2.3.4:443?sni=a.com#T")
check("M1 trojan parsed", t1 ~= nil and t1.proto == "trojan")
check("M1 trojan password url_decoded", t1 and t1.password == "p@ss:word")
check("M1 trojan server/port", t1 and t1.server == "1.2.3.4" and t1.port == 443)
-- ---------- M2:vmess classic JSON 的 host / path ----------
-- 未修复时 ws 的 Host 头与 path 全丢,服务端按默认路径匹配失败。
local vm_json = util.json_encode({
v = "2", ps = "V", add = "1.2.3.4", port = "443", id = "u", aid = "0",
net = "ws", type = "none", host = "ws.example.com", path = "/vmpath",
tls = "tls", sni = "a.com",
})
local t2 = one("vmess://" .. util.base64_encode(vm_json))
check("M2 vmess classic parsed", t2 ~= nil and t2.proto == "vmess")
check("M2 vmess keeps host", t2 and t2.host == "ws.example.com")
check("M2 vmess keeps path", t2 and t2.path == "/vmpath")
check("M2 vmess keeps net", t2 and t2.net == "ws")
-- 未提供 host/path 时不应凭空造出字段
local t2b = one("vmess://" .. util.base64_encode(util.json_encode({
v = "2", ps = "V2", add = "1.2.3.4", port = "443", id = "u", aid = "0", net = "tcp",
})))
check("M2 absent host stays nil", t2b and t2b.host == nil)
check("M2 absent path stays nil", t2b and t2b.path == nil)
-- ---------- M3:SSR 密码 base64 回退 ----------
-- 密码为纯 base64(非 base64url)时也必须解出明文,而不是乱码。
local ssr_main = "1.2.3.4:8080:origin:aes-128-cfb:plain:"
.. util.base64_url_encode("plainpass")
.. "/?remarks=" .. util.base64_url_encode("SSR")
local t3 = one("ssr://" .. util.base64_url_encode(ssr_main))
check("M3 ssr parsed", t3 ~= nil and t3.proto == "ssr")
check("M3 ssr password decoded", t3 and t3.password == "plainpass")
check("M3 ssr remarks decoded", t3 and t3.name == "SSR")
check("M3 ssr method/protocol/obfs", t3 and t3.method == "aes-128-cfb"
and t3.protocol == "origin" and t3.obfs == "plain")
-- 非 ASCII 备注必须原样还原(base64url → UTF-8),不能被解成乱码
local ssr_plain = "1.2.3.4:8080:origin:aes-128-cfb:plain:"
.. util.base64_url_encode("p") .. "/?remarks=" .. util.base64_url_encode("香港节点")
local t3b = one("ssr://" .. util.base64_url_encode(ssr_plain))
check("M3 ssr utf8 remarks preserved", t3b and t3b.name == "香港节点")
-- 外层用 base64url 字母表(含 - / _)的 ssr:// 链接同样必须正确解析。
-- 标准 base64 解码会把 -/_ 当非法字符**剔除**(util.lua 的 `[^%w%+/=]`),
-- 整串少掉若干字符、解成乱码,且不报错——用户只看到一个连不上的节点。
-- 这里的 server 取 1.2.3.100 是因为该 body 的标准 base64 恰好含 "/",
-- base64url 编码后变成 "_"(已实测确认)。
local ssr_b64u = "1.2.3.100:8080:origin:aes-128-cfb:plain:"
.. util.base64_url_encode("pw") .. "/?remarks=" .. util.base64_url_encode("U")
local outer = util.base64_url_encode(ssr_b64u)
check("M3 outer really uses base64url alphabet",
outer:find("-", 1, true) ~= nil or outer:find("_", 1, true) ~= nil)
-- 前提校验:用标准 base64 解码这份 base64url 串确实会出错(复现原缺陷)
local t3c_std = one("ssr://" .. outer)
check("M3 base64url outer decodes", t3c_std ~= nil and t3c_std.proto == "ssr")
check("M3 base64url outer server", t3c_std and t3c_std.server == "1.2.3.100")
check("M3 base64url outer password", t3c_std and t3c_std.password == "pw")
check("M3 base64url outer name", t3c_std and t3c_std.name == "U")
-- 标准 base64 外层仍然照旧
local outer_std = util.base64_encode(ssr_b64u)
local t3d = one("ssr://" .. outer_std)
check("M3 standard outer still works", t3d ~= nil and t3d.proto == "ssr"
and t3d.server == "1.2.3.100" and t3d.password == "pw")
-- ---------- M4:host / port 校验 ----------
-- 残缺节点会被写成 `server: ` / `port: 0`,mihomo 与 sing-box 会拒绝加载
-- 整份配置——一个节点废掉整个订阅。
local bad_uris = {
["vless port 0"] = "vless://uuid@1.2.3.4:0#A",
["vless port missing"] = "vless://uuid@1.2.3.4#B",
["vless port > 65535"] = "vless://uuid@1.2.3.4:99999#C",
["trojan port 0"] = "trojan://p@1.2.3.4:0#D",
["ss port 0"] = "ss://YWVzLTI1Ni1nY206cA==@1.2.3.4:0#E",
["hysteria2 port missing"] = "hysteria2://p@1.2.3.4#F",
["tuic port 0"] = "tuic://u:p@1.2.3.4:0#G",
}
for label, uri in pairs(bad_uris) do
local r = parser.parse(uri)
check("M4 dropped: " .. label, r ~= nil and #r.nodes == 0)
end
-- 合法边界值必须保留
local ok_port = one("vless://uuid@1.2.3.4:65535#OK")
check("M4 keeps port 65535", ok_port ~= nil and ok_port.port == 65535)
local ok_port1 = one("vless://uuid@1.2.3.4:1#OK1")
check("M4 keeps port 1", ok_port1 ~= nil and ok_port1.port == 1)
-- ---------- M5:sing-box YAML 嵌套 tls: map ----------
-- tls 是嵌套 map,且内部还含嵌套序列(alpn)与嵌套 map(utls)。
-- 未修复时整层 TLS 丢失:trojan/vmess/vless 静默退化成明文,
-- hysteria2/tuic 让客户端以 C.ErrTLSRequired 拒绝启动。
local sb_yaml = [[
outbounds:
- type: vless
tag: sb
server: 1.2.3.4
server_port: 443
uuid: u-1
flow: xtls-rprx-vision
tls:
enabled: true
server_name: sni.example.com
insecure: true
alpn:
- h2
- http/1.1
utls:
enabled: true
fingerprint: chrome
]]
local t5 = one(sb_yaml)
check("M5 sing-box yaml parsed", t5 ~= nil and t5.proto == "vless")
check("M5 tls enabled -> security", t5 and t5.security == "tls")
check("M5 server_name -> sni", t5 and t5.sni == "sni.example.com")
check("M5 insecure -> skip-cert-verify", t5 and t5["skip-cert-verify"] == true)
check("M5 flow preserved", t5 and t5.flow == "xtls-rprx-vision")
check("M5 alpn nested sequence", t5 and type(t5.alpn) == "table"
and t5.alpn[1] == "h2" and t5.alpn[2] == "http/1.1")
-- utls 排在 alpn 之后:若嵌套序列不被支持,read_map 会在 alpn 处截断,
-- 这个键会连带消失(这正是 read_seq 要解决的问题)
check("M5 utls fingerprint -> fp", t5 and t5.fp == "chrome")
-- 嵌套 map 不得以 table 形式漏到 tls 字段(table 恒为真值,
-- 会被下游的 `if n.tls then` 当成「已启用 TLS」)
check("M5 tls table not leaked", t5 and type(t5.tls) ~= "table")
-- tls.enabled 为假时不得开启 TLS。
-- 用 vless 而非 trojan:trojan 是 TLS-only,node.DEFAULTS 会强制补 security="tls",
-- 用它测不出「tls 块存在但未启用」这条分支(vless 的默认值是 "none")。
local sb_off = [[
outbounds:
- type: vless
tag: off
server: 1.2.3.4
server_port: 443
uuid: u-off
tls:
enabled: false
server_name: s.example.com
]]
local t5b = one(sb_off)
check("M5 tls disabled stays off", t5b and (t5b.security == nil or t5b.security == "none"))
-- ---------- M7:通用 JSON 节点用 type 映射协议 + 白名单 ----------
local t7 = one(util.json_encode({
{ type = "trojan", server = "1.2.3.4", port = 443, password = "p", name = "J" },
}))
check("M7 json type -> proto", t7 ~= nil and t7.proto == "trojan")
check("M7 json password kept", t7 and t7.password == "p")
-- type 已被消费为协议,不能原样留在节点上(vmess 的 type 是 header 类型,语义不同)
check("M7 type consumed", t7 and t7.type == nil)
-- 未知类型必须丢弃,而不是兜底成 vmess 造出字段全错的假节点
local t7b = parser.parse(util.json_encode({
{ type = "snell", server = "1.2.3.4", port = 443, password = "p" },
}))
check("M7 unknown type dropped", t7b ~= nil and #t7b.nodes == 0)
-- 无 server/port 的条目丢弃
local t7c = parser.parse(util.json_encode({
{ type = "vmess", server = "", port = 443, uuid = "u" },
}))
check("M7 empty server dropped", t7c ~= nil and #t7c.nodes == 0)
-- ---------- M8:Surge 段名大小写不敏感 ----------
check("M8 is_config [Proxy]", surge.is_config("[Proxy]\nA = vmess, 1.2.3.4, 443\n"))
check("M8 is_config [PROXY]", surge.is_config("[PROXY]\nA = vmess, 1.2.3.4, 443\n"))
check("M8 is_config [server_local]", surge.is_config("[server_local]\nvmess=1.2.3.4:443\n"))
check("M8 is_config negative", not surge.is_config("proxies:\n - name: x\n"))
-- 全大写段名必须能整份解析(未修复时被判成 JSON 数组 → 整份报错)
local t8 = parser.parse("[PROXY]\nA = vmess, 1.2.3.4, 443, username=u\n")
check("M8 uppercase section parses", t8 ~= nil and #t8.nodes == 1)
check("M8 uppercase node proto", t8 and t8.nodes[1] and t8.nodes[1].proto == "vmess")
-- Surge / QX 未知协议白名单(H6 的 Surge 侧)
check("M8/H6 surge unknown proto dropped",
#surge.parse("[Proxy]\nA = snell, 1.2.3.4, 443, psk=x\n") == 0)
check("M8/H6 qx unknown proto dropped",
#surge.parse("[server_local]\nssh=1.2.3.4:22, password=p\n") == 0)
-- 已知协议仍正常
check("M8/H6 surge known proto kept",
#surge.parse("[Proxy]\nA = trojan, 1.2.3.4, 443, password=p\n") == 1)
-- ---------- M10:userinfo 按最后一个 @ 切分 ----------
-- 密码里含未转义的 @ 时,按首个 @ 切分会把密码截断、host 变成 "ss@1.2.3.4"。
local t10 = one("hysteria2://p@ss@1.2.3.4:443?sni=a.com#H")
check("M10 hysteria2 parsed", t10 ~= nil and t10.proto == "hysteria2")
check("M10 password keeps @", t10 and t10.password == "p@ss")
check("M10 server correct", t10 and t10.server == "1.2.3.4")
check("M10 port correct", t10 and t10.port == 443)
-- 多个 @ 也按最后一个切
local t10b = one("trojan://a@b@c@1.2.3.4:443#T")
check("M10 multiple @ in password", t10b and t10b.password == "a@b@c"
and t10b.server == "1.2.3.4")
-- ---------- M12:core.load 遇坏条目不崩溃 ----------
local tmp = os.tmpname() .. "_p1fix"
os.remove(tmp)
core.DATA_DIR = tmp
core.LIST_FILE = tmp .. "/subscriptions.json"
core.NODES_DIR = tmp .. "/nodes"
core.ensure_dirs()
os.execute("mkdir -p " .. util.shq(tmp))
local fh = io.open(core.LIST_FILE, "w")
fh:write('{"seq":3,"items":{"good":{"name":"G","url":"http://e.com/s"},"bad":"not-a-table"}}')
fh:close()
-- 未修复时 M.list 里的 pairs(meta) 会抛
-- "bad argument #1 to 'pairs' (table expected, got string)",订阅列表页直接 500。
-- 注意 core.load 是模块内局部函数,对外只能通过 core.list() 观察。
local arr, lerr = core.list()
check("M12 list does not crash", type(arr) == "table")
check("M12 good entry kept", #arr == 1 and arr[1].id == "good" and arr[1].name == "G")
check("M12 corruption reported", type(lerr) == "string" and lerr:find("corrupted") ~= nil)
-- 坏条目不能因为「静默过滤」而被当成正常列表:写路径必须拒绝落盘,
-- 否则下一次 save 会把坏条目永久抹掉
local wrote = core.save_meta("good", { upload = 1 })
check("M12 write refused on corruption", wrote == nil or wrote == false)
-- 全部条目合法时不报错
local fh2 = io.open(core.LIST_FILE, "w")
fh2:write('{"_seq":4,"items":{"a":{"name":"A"}}}')
fh2:close()
local arr2, lerr2 = core.list()
check("M12 clean file no error", lerr2 == nil and #arr2 == 1 and arr2[1].id == "a")
os.execute("rm -rf " .. util.shq(tmp))
-- ---------- M15:改名替换串的 % 与 $ ----------
local function rename_one(name, rules)
local ns = node.rename_with_rules(
{ { name = name, proto = "vmess", server = "1.2.3.4", port = 443 } }, rules)
return ns[1].name
end
-- 结尾的 % 未转义会注入 NUL 字节("100%" → "100\0"),
-- 节点名会写进节点文件并下发给所有客户端
local m15a = rename_one("x", { { type = "regex", pattern = "x", replacement = "100%" } })
check("M15 trailing percent preserved", m15a == "100%")
check("M15 no NUL injected", m15a:find("%z") == nil)
check("M15 percent length exact", #m15a == 4)
-- % 后接非数字字符未转义会被静默吞掉("50%off" → "50off")
local m15b = rename_one("x", { { type = "regex", pattern = "x", replacement = "50%off" } })
check("M15 percent before letter preserved", m15b == "50%off")
check("M15 percent before letter length", #m15b == 6)
-- 捕获引用仍可用
local m15c = rename_one("ab", { { type = "regex", pattern = "(a)(b)", replacement = "$2$1" } })
check("M15 backreference still works", m15c == "ba")
-- %% 按字面 % 处理(用户写了两个就得到两个)
local m15d = rename_one("x", { { type = "regex", pattern = "x", replacement = "a%%b" } })
check("M15 double percent literal", m15d == "a%%b")
-- ---------- M20:output_v2ray 协议白名单 ----------
-- Xray 只认 vmess/vless/trojan/shadowsocks/socks/http;其余协议没有对应
-- outbound 类型,写成 `"protocol": "hysteria2"` 会让 Xray 拒绝整份配置。
local m20_nodes = {
{ proto = "vmess", name = "V", server = "1.1.1.1", port = 443, uuid = "u" },
{ proto = "vless", name = "L", server = "2.2.2.2", port = 443, uuid = "u2" },
{ proto = "trojan", name = "T", server = "3.3.3.3", port = 443, password = "p" },
{ proto = "ss", name = "S", server = "4.4.4.4", port = 8388, method = "aes-256-gcm", password = "p" },
{ proto = "socks", name = "SK", server = "5.5.5.5", port = 1080, username = "u", password = "p" },
{ proto = "hysteria2", name = "H2", server = "6.6.6.6", port = 443, password = "p" },
{ proto = "hysteria", name = "H1", server = "7.7.7.7", port = 443, password = "p" },
{ proto = "tuic", name = "TU", server = "8.8.8.8", port = 443, uuid = "u3", password = "p" },
{ proto = "wireguard", name = "WG", server = "9.9.9.9", port = 51820, ["private-key"] = "k" },
{ proto = "ssr", name = "SSR", server = "10.10.10.10", port = 8080, password = "p" },
}
local m20_out = output.generate(m20_nodes, "v2ray")
local m20_cfg = util.json_decode(m20_out)
local m20_protos = {}
for _, o in ipairs(m20_cfg and m20_cfg.outbounds or {}) do
m20_protos[o.protocol] = true
end
for _, bad in ipairs({ "hysteria2", "hysteria", "tuic", "wireguard", "ssr" }) do
check("M20 v2ray drops " .. bad, not m20_protos[bad])
end
check("M20 v2ray keeps vmess", m20_protos.vmess)
check("M20 v2ray keeps vless", m20_protos.vless)
check("M20 v2ray keeps trojan", m20_protos.trojan)
check("M20 v2ray maps ss ->shadowsocks", m20_protos.shadowsocks)
check("M20 v2ray keeps socks", m20_protos.socks)
check("M20 v2ray keeps freedom/blackhole", m20_protos.freedom and m20_protos.blackhole)
-- 被放行的 socks 凭据必须真的写进 users(否则等于放行了个空壳)
local m20_socks
for _, o in ipairs(m20_cfg.outbounds) do
if o.protocol == "socks" then m20_socks = o end
end
check("M20 socks credentials kept", m20_socks ~= nil
and m20_socks.settings.servers[1].users[1].user == "u")
local m20_ss
for _, o in ipairs(m20_cfg.outbounds) do
if o.protocol == "shadowsocks" then m20_ss = o end
end
check("M20 ss method kept", m20_ss ~= nil
and m20_ss.settings.servers[1].method == "aes-256-gcm")
-- 被丢弃的节点不能残留出站(tag 会进 balancer selector)
check("M20 dropped nodes produce no outbound",
#m20_cfg.outbounds == 7) -- vmess/vless/trojan/ss/socks + freedom/blackhole
-- ---------- M21:clashmeta 写 flow ----------
-- flow 是 XTLS Vision(xtls-rprx-vision)的必需参数;不写的话 mihomo 按普通
-- vless 处理,服务端要求 vision 时握手失败。surge / v2ray / URI 三个输出都写
-- flow,只有 clashmeta 漏了。
local m21 = output_clash_meta.generate({
{ proto = "vless", name = "L", server = "1.1.1.1", port = 443,
uuid = "u", flow = "xtls-rprx-vision", security = "tls", sni = "e.com" },
})
check("M21 vless flow written", m21:find("flow: xtls%-rprx%-vision") ~= nil)
-- vmess 没有 flow 概念,不得凭空写出该键
local m21b = output_clash_meta.generate({
{ proto = "vmess", name = "V", server = "1.1.1.1", port = 443, uuid = "u" },
})
check("M21 vmess has no flow", m21b:find("flow:", 1, true) == nil)
-- 无 flow 的 vless 也不写空键
local m21c = output_clash_meta.generate({
{ proto = "vless", name = "L2", server = "1.1.1.1", port = 443, uuid = "u" },
})
check("M21 vless without flow omits key", m21c:find("flow:", 1, true) == nil)
-- ---------- M22:clashmeta 的 grpc / h2 传输参数 ----------
-- 只写 network: grpc 时客户端用默认服务名去连,握手失败——与 ws 丢 path 同类。
-- 键名对照上游 transport 文档:grpc-service-name(带 grpc- 前缀);
-- 服务名存在 node.path(与 output_v2ray 的 serviceName、sing-box 的 service_name 同源)。
local m22g = output_clash_meta.generate({
{ proto = "vless", name = "G", server = "1.1.1.1", port = 443,
uuid = "u", net = "grpc", path = "my-grpc-svc", security = "tls" },
})
check("M22 grpc network written", m22g:find("network: grpc") ~= nil)
check("M22 grpc-opts block", m22g:find("grpc%-opts:") ~= nil)
check("M22 grpc-service-name", m22g:find("grpc%-service%-name: my%-grpc%-svc") ~= nil)
-- h2-opts.host 是**列表**,h2-opts.path 是标量(上游 transport 文档)
local m22h = output_clash_meta.generate({
{ proto = "trojan", name = "H", server = "2.2.2.2", port = 443, password = "p",
net = "h2", path = "/h2path", host = "h2.example.com" },
})
check("M22 h2 network written", m22h:find("network: h2") ~= nil)
check("M22 h2-opts block", m22h:find("h2%-opts:") ~= nil)
check("M22 h2 host is a list", m22h:find("host:\n %- h2%.example%.com") ~= nil)
check("M22 h2 path scalar", m22h:find("path: /h2path") ~= nil)
-- 无 path 的 grpc 节点不写出空 grpc-opts 块
local m22e = output_clash_meta.generate({
{ proto = "vless", name = "E", server = "4.4.4.4", port = 443, uuid = "u", net = "grpc" },
})
check("M22 grpc without path omits opts", m22e:find("grpc%-opts:", 1, true) == nil)
-- grpc 节点不得写出 h2-opts,反之亦然
check("M22 grpc has no h2-opts", m22g:find("h2%-opts:", 1, true) == nil)
check("M22 h2 has no grpc-opts", m22h:find("grpc%-opts:", 1, true) == nil)
-- ws 行为不变(回归保护)
local m22w = output_clash_meta.generate({
{ proto = "vmess", name = "W", server = "3.3.3.3", port = 443, uuid = "u",
net = "ws", path = "/ws", host = "ws.example.com" },
})
check("M22 ws-opts unchanged", m22w:find("ws%-opts:") ~= nil
and m22w:find("path: /ws") ~= nil
and m22w:find("Host: ws%.example%.com") ~= nil)
-- ---------- 结果 ----------
print(string.format("\n%d passed, %d failed", passed, failed))
os.exit(failed == 0 and 0 or 1)