🌈 Sync 2026-10-01 13:09:37

This commit is contained in:
github-actions[bot] committed 2026-10-01 13:09:37 +08:00
1 parent dceb6de0e4
commit 889951b6d0
116 files changed
+6376 -1093

No files matched your search

+410
View File
@@ -0,0 +1,410 @@
-- 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("损坏") ~= 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)