mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-27 11:32:52 +08:00
305 lines
16 KiB
Lua
305 lines
16 KiB
Lua
-- output_formats_test.lua — 13 种目标格式统一分发单元测试
|
||
-- 用法:lua5.1 tests/output_formats_test.lua
|
||
|
||
package.path = "./root/usr/share/?.lua;" .. package.path
|
||
|
||
local output = require("substore.output")
|
||
local fmts = require("substore.output_formats")
|
||
local util = require("substore.util")
|
||
|
||
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
|
||
|
||
local nodes = {
|
||
{ proto = "vmess", name = "VMess Node", server = "1.1.1.1", port = 443,
|
||
uuid = "abc-123", net = "ws", security = "tls", sni = "example.com",
|
||
path = "/ws", host = "example.com" },
|
||
{ proto = "vless", name = "VLESS Node", server = "2.2.2.2", port = 443,
|
||
uuid = "vless-uuid", security = "tls", sni = "example.org" },
|
||
{ proto = "trojan", name = "Trojan Node", server = "3.3.3.3", port = 443,
|
||
password = "trojan-pass", sni = "example.com" },
|
||
{ proto = "shadowsocks", name = "SS Node", server = "4.4.4.4", port = 8388,
|
||
method = "aes-256-gcm", password = "ss-pass" },
|
||
}
|
||
|
||
check("module exists", output ~= nil)
|
||
check("generate exists", type(output.generate) == "function")
|
||
|
||
-- 各目标格式均返回字符串
|
||
local formats = {
|
||
"clash", "clashmeta", "mihomo", "stash",
|
||
"surge", "surfboard", "surgemac", "loon", "egern",
|
||
"qx", "singbox", "v2ray", "v2rayuri", "shadowrocket", "plain",
|
||
}
|
||
|
||
for _, f in ipairs(formats) do
|
||
local s, err = output.generate(nodes, f)
|
||
check("generate " .. f .. " returns string", type(s) == "string" and (not err))
|
||
print(" ", err or "")
|
||
end
|
||
|
||
-- 格式头检查
|
||
local surge = output.generate(nodes, "surge")
|
||
check("surge has [Proxy]", surge:find("%[Proxy%]") ~= nil)
|
||
check("surge has vmess line", surge:find("VMess Node = vmess") ~= nil)
|
||
check("surge has ss line", surge:find("SS Node = shadowsocks") ~= nil)
|
||
|
||
local qx = output.generate(nodes, "qx")
|
||
check("qx has [server_local]", qx:find("%[server_local%]") ~= nil)
|
||
check("qx has vmess line", qx:find("vmess=1.1.1.1:443") ~= nil)
|
||
check("qx has policy", qx:find("%[policy%]") ~= nil)
|
||
|
||
-- sing-box / v2ray 自 2.4.0 起输出完整配置:outbounds 除节点外还含
|
||
-- selector / urltest(sing-box)与 direct / block,故此处不再断言节点数量,
|
||
-- 改为断言节点出站按序在前、且结构与分流齐全(详见 output_full_config_test.lua)
|
||
local singbox = output.generate(nodes, "singbox")
|
||
check("singbox is json", singbox:find("{") == 1)
|
||
local sb = util.json_decode(singbox)
|
||
check("singbox has outbounds", sb and sb.outbounds ~= nil and #sb.outbounds == 4 + 4)
|
||
check("singbox type vmess", sb and sb.outbounds[1] and sb.outbounds[1].type == "vmess")
|
||
|
||
local v2ray = output.generate(nodes, "v2ray")
|
||
local vr = util.json_decode(v2ray)
|
||
check("v2ray has outbounds", vr and vr.outbounds ~= nil and #vr.outbounds == 4 + 2)
|
||
check("v2ray protocol vmess", vr and vr.outbounds[1] and vr.outbounds[1].protocol == "vmess")
|
||
|
||
local uri = output.generate(nodes, "v2rayuri")
|
||
check("uri list has vmess", uri:find("vmess://") ~= nil)
|
||
check("uri list has vless", uri:find("vless://") ~= nil)
|
||
check("uri list has trojan", uri:find("trojan://") ~= nil)
|
||
check("uri list has ss", uri:find("ss://") ~= nil)
|
||
|
||
local rocket = output.generate(nodes, "shadowrocket")
|
||
local dec = util.base64_decode(rocket)
|
||
check("shadowrocket is base64 of uri", dec:find("vmess://") ~= nil)
|
||
|
||
local plain = output.generate(nodes, "plain")
|
||
local pj = util.json_decode(plain)
|
||
check("plain is json array", type(pj) == "table" and pj[1] ~= nil and pj[1].proto == "vmess")
|
||
|
||
-- 别名映射
|
||
-- 注意:clash 自 2.3.0-r2 起指"Clash 原版"(会过滤 vless/hysteria2/tuic/wireguard),
|
||
-- 不再是 clashmeta 的别名;clashmeta 的别名是 yaml / mihomo
|
||
check("alias yaml -> clashmeta", output.generate(nodes, "clashmeta") == output.generate(nodes, "yaml"))
|
||
check("alias mihomo -> clashmeta", output.generate(nodes, "clashmeta") == output.generate(nodes, "mihomo"))
|
||
check("alias sing_box", output.generate(nodes, "sing_box") == output.generate(nodes, "singbox"))
|
||
|
||
-- 未知格式报错
|
||
local bad, err = output.generate(nodes, "nonsense")
|
||
check("unknown format returns nil", bad == nil)
|
||
check("unknown format has err", err ~= nil)
|
||
|
||
-- 空节点
|
||
check("empty nodes ok", type(output.generate({}, "surge")) == "string")
|
||
|
||
-- H14:tuic 的 alpn 可能是数组(sing-box JSON / Clash YAML 的 alpn 列表导入后即为
|
||
-- table),直接 "alpn=" .. n.alpn 会 attempt to concatenate a table value,
|
||
-- 让整次导出直接报错。
|
||
local ok_alpn, line_alpn = pcall(fmts.surge_line, { proto = "tuic", name = "T", server = "1.2.3.4",
|
||
port = 443, uuid = "u", password = "p", security = "tls", sni = "s.example.com",
|
||
alpn = { "h3", "h2" } })
|
||
check("tuic alpn array does not raise", ok_alpn)
|
||
-- 多值 alpn 在 Surge 家族的行语法里表达不了(逗号分隔的 key=value,无转义),
|
||
-- 拼成 `alpn=h3,h2` 会被读成 `alpn=h3` 加一个悬空字段。该参数被省略、
|
||
-- 节点本身保留 —— alpn 只是协商提示,缺省时客户端用服务端给出的列表。
|
||
check("tuic alpn array omitted, node kept",
|
||
ok_alpn and line_alpn ~= nil and line_alpn:find("alpn=", 1, true) == nil
|
||
and line_alpn:find("tuic, 1.2.3.4, 443", 1, true) ~= nil)
|
||
check("tuic alpn string still works",
|
||
fmts.surge_line({ proto = "tuic", name = "T", server = "1.2.3.4", port = 443, uuid = "u",
|
||
alpn = "h3" }):find("alpn=h3", 1, true) ~= nil)
|
||
|
||
-- H7:节点名 / 参数值来自订阅内容(不可信输入),含换行会截断当前行并伪造出
|
||
-- 一条新的代理行。
|
||
local nl_line = fmts.surge_line({ proto = "trojan", name = "A\nB = trojan, 6.6.6.6, 443, password=x",
|
||
server = "1.1.1.1", port = 443, password = "p", security = "tls" })
|
||
check("surge line has no newline", nl_line:find("\n") == nil)
|
||
check("surge line has no CR", nl_line:find("\r") == nil)
|
||
check("surge line keeps name prefix", nl_line:sub(1, 1) == "A")
|
||
|
||
local function count_lines(s)
|
||
local c = 0
|
||
for _ in s:gmatch("\n") do c = c + 1 end
|
||
return c
|
||
end
|
||
|
||
-- 代理组行同样不能被节点名注入:压平后的名字不得让输出多出一行,
|
||
-- 也不得产生一条以注入内容开头的新行
|
||
local grp = fmts.to_surge({ { proto = "trojan", name = "G\nINJECT = trojan, 9.9.9.9, 443, password=q",
|
||
server = "1.1.1.1", port = 443, password = "p", security = "tls" } })
|
||
local grp_ok = fmts.to_surge({ { proto = "trojan", name = "GINJECT = trojan, 9.9.9.9, 443, password=q",
|
||
server = "1.1.1.1", port = 443, password = "p", security = "tls" } })
|
||
check("surge group newline name adds no line", count_lines(grp) == count_lines(grp_ok))
|
||
local starts_inject = false
|
||
for l in grp:gmatch("[^\n]+") do
|
||
if l:sub(1, 6) == "INJECT" then starts_inject = true end
|
||
end
|
||
check("surge group line not injected", starts_inject == false)
|
||
|
||
-- Quantumult X:节点名含换行不得让输出多出一行
|
||
local qx_nl = fmts.to_qx({ { proto = "trojan", name = "X\nY", server = "1.1.1.1", port = 443,
|
||
password = "p" } })
|
||
local qx_ok = fmts.to_qx({ { proto = "trojan", name = "XY", server = "1.1.1.1", port = 443,
|
||
password = "p" } })
|
||
check("qx newline name does not add lines", count_lines(qx_nl) == count_lines(qx_ok))
|
||
check("qx newline name flattened", qx_nl:find("X Y", 1, true) ~= nil)
|
||
|
||
-- ---------- 7.9:Loon 与 Surge 家族的参数名 / 写法分叉 ----------
|
||
-- 依据:nsloon.app/docs/Node/(Loon 节点行)与 manual.nssurge.com/policies/*.html
|
||
-- (Surge 家族)。两家语法在四处不同:TLS 开关名、UDP 开关名、VMess 加密方式的
|
||
-- 落点、以及凭据是否写成端口之后的位置参数。写错任何一处都不会报错,只是节点
|
||
-- 连不上 —— 所以每处分叉都钉一条断言。
|
||
|
||
-- (a) TLS 开关名:Surge 家族 tls=true,Loon over-tls=true
|
||
local vm_tls = { proto = "vmess", name = "V", server = "1.1.1.1", port = 443,
|
||
uuid = "u", security = "tls" }
|
||
check("surge vmess tls=true", fmts.surge_line(vm_tls, "surge"):find("tls=true", 1, true) ~= nil)
|
||
check("loon vmess over-tls=true", fmts.surge_line(vm_tls, "loon"):find("over-tls=true", 1, true) ~= nil)
|
||
-- 具名参数之间是 ", " 分隔,所以独立的 tls 参数会以 ", tls=true" 出现;
|
||
-- 直接找 "tls=true" 会命中 "over-tls=true" 的子串,必须带上前导分隔符。
|
||
check("loon vmess no bare tls=true",
|
||
fmts.surge_line(vm_tls, "loon"):find(", tls=true", 1, true) == nil)
|
||
|
||
local vl_tls = { proto = "vless", name = "L", server = "1.1.1.1", port = 443,
|
||
uuid = "u", security = "tls" }
|
||
check("surge vless tls=true", fmts.surge_line(vl_tls, "surge"):find("tls=true", 1, true) ~= nil)
|
||
check("loon vless over-tls=true", fmts.surge_line(vl_tls, "loon"):find("over-tls=true", 1, true) ~= nil)
|
||
|
||
-- Trojan 是例外:Loon 文档的 Trojan 示例(明文与 Reality 两条)里根本没有 TLS 键
|
||
-- —— Trojan 本身就建立在 TLS 之上,Loon 不再要这个开关。Surge 家族的 tls=true
|
||
-- 则是文档要求的,不能跟着一起删。
|
||
local tj_node = { proto = "trojan", name = "T", server = "1.1.1.1", port = 443, password = "p" }
|
||
check("surge trojan tls=true", fmts.surge_line(tj_node, "surge"):find("tls=true", 1, true) ~= nil)
|
||
local loon_tj = fmts.surge_line(tj_node, "loon")
|
||
check("loon trojan has no tls key", loon_tj:find("tls", 1, true) == nil)
|
||
check("loon trojan positional password", loon_tj:find('trojan, 1.1.1.1, 443, "p"', 1, true) ~= nil)
|
||
|
||
-- (b) UDP 开关名:Surge 家族 udp-relay=true,Loon udp=true
|
||
local udp_node = { proto = "trojan", name = "U", server = "1.1.1.1", port = 443,
|
||
password = "p", udp = true }
|
||
check("surge udp-relay=true", fmts.surge_line(udp_node, "surge"):find("udp-relay=true", 1, true) ~= nil)
|
||
local loon_udp = fmts.surge_line(udp_node, "loon")
|
||
check("loon udp=true", loon_udp:find("udp=true", 1, true) ~= nil)
|
||
check("loon has no udp-relay", loon_udp:find("udp-relay", 1, true) == nil)
|
||
|
||
-- (c) Surge 家族的 VMess 加密方式写在具名参数 encrypt-method 上,取值只有
|
||
-- aes-128-gcm / chacha20-ietf-poly1305(默认前者)。模型的 chacha20-poly1305
|
||
-- 与客户端拼写不同,不写出来 Surge 会按默认的 aes-128-gcm 去连 —— 静默用错算法。
|
||
local vm_chacha = { proto = "vmess", name = "C", server = "1.1.1.1", port = 443,
|
||
uuid = "u", cipher = "chacha20-poly1305" }
|
||
check("surge vmess chacha mapped",
|
||
fmts.surge_line(vm_chacha, "surge"):find("encrypt-method=chacha20-ietf-poly1305", 1, true) ~= nil)
|
||
local vm_gcm = { proto = "vmess", name = "C", server = "1.1.1.1", port = 443,
|
||
uuid = "u", cipher = "aes-128-gcm" }
|
||
check("surge vmess aes-128-gcm",
|
||
fmts.surge_line(vm_gcm, "surge"):find("encrypt-method=aes-128-gcm", 1, true) ~= nil)
|
||
-- 不在客户端清单里的取值(zero / none / auto / aes-128-cfb)不写该参数、节点保留:
|
||
-- 写一个客户端读不懂的取值比不写更糟
|
||
local vm_zero = { proto = "vmess", name = "C", server = "1.1.1.1", port = 443,
|
||
uuid = "u", cipher = "zero" }
|
||
local zero_line = fmts.surge_line(vm_zero, "surge")
|
||
check("surge vmess unmapped cipher omitted",
|
||
zero_line ~= nil and zero_line:find("encrypt-method", 1, true) == nil)
|
||
-- Loon 把加密方式放在位置参数上,不该出现具名的 encrypt-method
|
||
check("loon vmess no named encrypt-method",
|
||
fmts.surge_line(vm_chacha, "loon"):find("encrypt-method", 1, true) == nil)
|
||
|
||
-- (d) Loon 的 shadowsocks / hysteria2 凭据同样是位置参数(密码带双引号)
|
||
local ss_node = { proto = "shadowsocks", name = "S", server = "1.1.1.1", port = 8388,
|
||
method = "aes-256-gcm", password = "pw" }
|
||
check("surge ss named", fmts.surge_line(ss_node, "surge"):find("encrypt-method=aes-256-gcm", 1, true) ~= nil)
|
||
check("loon ss positional",
|
||
fmts.surge_line(ss_node, "loon"):find('shadowsocks, 1.1.1.1, 8388, aes-256-gcm, "pw"', 1, true) ~= nil)
|
||
|
||
local hy_node = { proto = "hysteria2", name = "H", server = "1.1.1.1", port = 443, password = "pw" }
|
||
check("surge hysteria2 named", fmts.surge_line(hy_node, "surge"):find("password=pw", 1, true) ~= nil)
|
||
check("loon hysteria2 positional",
|
||
fmts.surge_line(hy_node, "loon"):find('hysteria2, 1.1.1.1, 443, "pw"', 1, true) ~= nil)
|
||
|
||
-- (e) 值里含英文逗号:Loon 的引号按文档确实能保住逗号,本实现仍按「整条丢弃」
|
||
-- 处理(具名参数不带引号,同一行混用两种约定会让「哪个值被包裹」变成依赖客户端
|
||
-- 实现的行为)。这里钉住这个保守选择,避免日后被无意改成半截输出。
|
||
local ss_comma = { proto = "shadowsocks", name = "S", server = "1.1.1.1", port = 8388,
|
||
method = "aes-256-gcm", password = "pa,ss" }
|
||
check("loon comma password drops node", fmts.surge_line(ss_comma, "loon") == nil)
|
||
|
||
-- ---------- Loon 输出 → 导入回环 ----------
|
||
-- 生成端与解析端必须成对改动:只改生成端的话,导出的 Loon 配置再导入回来会静默
|
||
-- 丢掉凭据(parser_surge 此前对 ssr 根本没有取字段的分支,对 shadowsocks /
|
||
-- hysteria2 也不认位置参数)。
|
||
local psurge = require("substore.parser_surge")
|
||
local function loon_roundtrip(node)
|
||
return psurge.parse(fmts.to_loon({ node }, { name = "P" }))[1]
|
||
end
|
||
|
||
local rt_ss = loon_roundtrip(ss_node)
|
||
check("loon rt ss method", rt_ss and rt_ss.method == "aes-256-gcm")
|
||
check("loon rt ss password", rt_ss and rt_ss.password == "pw")
|
||
|
||
local rt_hy = loon_roundtrip(hy_node)
|
||
check("loon rt hysteria2 password", rt_hy and rt_hy.password == "pw")
|
||
check("loon rt hysteria2 security", rt_hy and rt_hy.security == "tls")
|
||
|
||
local rt_vm = loon_roundtrip(vm_tls)
|
||
check("loon rt vmess uuid", rt_vm and rt_vm.uuid == "u")
|
||
check("loon rt vmess security", rt_vm and rt_vm.security == "tls")
|
||
|
||
local rt_vl = loon_roundtrip(vl_tls)
|
||
check("loon rt vless uuid", rt_vl and rt_vl.uuid == "u")
|
||
check("loon rt vless security", rt_vl and rt_vl.security == "tls")
|
||
|
||
-- Trojan:Loon 行里不再有 TLS 开关,security 由 node.normalize 的 TLS_ONLY 补回
|
||
local rt_tj = loon_roundtrip(tj_node)
|
||
check("loon rt trojan password", rt_tj and rt_tj.password == "p")
|
||
check("loon rt trojan security", rt_tj and rt_tj.security == "tls")
|
||
|
||
-- ---------- 7.9(g)+(j):Loon / Surge / SurgeMac 都没有 Hysteria v1 ----------
|
||
-- Loon 的节点类型清单里只有 Hysteria2(nsloon.app/docs/Node/)。注意 hysteria
|
||
-- **2** 是各家通用的,被丢的只有上一代 v1 —— 两者共用同一个输出分支,容易误伤。
|
||
local hy1_node = { proto = "hysteria", name = "H1", server = "1.1.1.1", port = 443, password = "p" }
|
||
local hy2_node = { proto = "hysteria2", name = "H2", server = "1.1.1.1", port = 443, password = "p" }
|
||
local loon_hy = fmts.to_loon({ hy1_node, hy2_node }, { name = "P" })
|
||
check("loon drops hysteria v1", loon_hy:find("hysteria, 1.1.1.1", 1, true) == nil)
|
||
check("loon keeps hysteria2", loon_hy:find('hysteria2, 1.1.1.1, 443, "p"', 1, true) ~= nil)
|
||
-- 被丢弃的节点也不能留在 [Proxy Group] 的成员列表里(否则组引用一个不存在的代理)
|
||
check("loon group has no hysteria v1", loon_hy:find("H1", 1, true) == nil)
|
||
check("loon group keeps hysteria2", loon_hy:find("H2", 1, true) ~= nil)
|
||
|
||
-- (j):Surge / SurgeMac 的手册协议清单写的也是 "Hysteria 2",
|
||
-- manual.nssurge.com/policies/hysteria.html 是 404 而 hysteria2.html 存在。
|
||
-- Surfboard 未获证据(其文档 404),**不**跟着丢 —— 这条断言防止有人顺手扩大范围。
|
||
local surge_hy = fmts.to_surge({ hy1_node, hy2_node }, { name = "P" })
|
||
check("surge drops hysteria v1", surge_hy:find("hysteria, 1.1.1.1", 1, true) == nil)
|
||
check("surge keeps hysteria2", surge_hy:find("hysteria2, 1.1.1.1", 1, true) ~= nil)
|
||
check("surge group has no hysteria v1", surge_hy:find("H1", 1, true) == nil)
|
||
local mac_hy = fmts.to_surgemac({ hy1_node }, { name = "P" })
|
||
check("surgemac drops hysteria v1", mac_hy:find("hysteria, 1.1.1.1", 1, true) == nil)
|
||
local sb_hy = fmts.to_surfboard({ hy1_node }, { name = "P" })
|
||
check("surfboard keeps hysteria v1 (no evidence to drop)",
|
||
sb_hy:find("hysteria, 1.1.1.1", 1, true) ~= nil)
|
||
|
||
-- ---------- 7.9(h):skip-cert-verify 写 true/false ----------
|
||
-- Loon 文档的示例是 skip-cert-verify=false;Surge 手册只写 "boolean"。
|
||
-- 两家文档里都找不到 `1` 这个取值。
|
||
local scv_node = { proto = "hysteria2", name = "S", server = "1.1.1.1", port = 443,
|
||
password = "p", ["skip-cert-verify"] = true }
|
||
check("surge skip-cert-verify=true",
|
||
fmts.surge_line(scv_node, "surge"):find("skip-cert-verify=true", 1, true) ~= nil)
|
||
check("loon skip-cert-verify=true",
|
||
fmts.surge_line(scv_node, "loon"):find("skip-cert-verify=true", 1, true) ~= nil)
|
||
check("no skip-cert-verify=1",
|
||
fmts.surge_line(scv_node, "surge"):find("skip-cert-verify=1", 1, true) == nil)
|
||
local rt_scv = loon_roundtrip(scv_node)
|
||
check("loon rt skip-cert-verify", rt_scv and rt_scv["skip-cert-verify"] == true)
|
||
|
||
print(string.format("\n%d passed, %d failed", passed, failed))
|
||
os.exit(failed == 0 and 0 or 1) |