Files
MeBox/internal/service/reader/rule/js_test.go
T
truewhile 5976a8d310 feat(reader): P2——goja JS 引擎接入 + Go 原生 java.* 桥接
- rule/goja.go:纯 Go JS 运行时(goja),脚本编译缓存 + 超时中断(默认10s),
  绑定集合对齐 legado evalJS(java/cookie/cache/source/book/result/baseUrl/
  chapter/title/src/page/key)
- rule/bridge.go:java 对象函数名与 legado JsExtensions 一一对应(存量书源
  硬编码这些名字),全部 Go 原生实现:
  网络 ajax/ajaxAll/connect/get/post/head(经 MeBox HTTP 客户端,响应对象
  带 body()/code()/url()/header());规则回调 getString/getStringList/
  getElements/put/get(桥回当前解析器);编解码 base64/hex/md5(16/32)/
  digest/HMac/encodeURI/timeFormat(SimpleDateFormat 转 Go 布局)
- 对称加密:createSymmetricCrypto + aes/des/3des 全家桶,transformation
  三段式解析(AES/DES/3DES × CBC/ECB/CTR/OFB/CFB × PKCS5/7/Zero/NoPadding,
  ECB 手工块循环),密钥长度自动补齐,decryptStr 自动识别 Base64/Hex
- get(url,headers) 与 get(key) 按 arity 分发(对齐 Kotlin 重载)
- 浏览器/文件/压缩包类函数明确抛不支持(服务端沙箱边界)
- URL 规则 JS:analyzeJs(@result 链式)、{{js}} 内嵌、option js/bodyJs
- execute 记录 Set-Cookie 进 cookie jar(java/cookie.getCookie 可读)
- 测试:编码/摘要/AES 向量/CBC+ECB 往返/ajax httptest/URL JS/沙箱边界,
  全部通过;修复 java.get 双注册、测试缺 SetContent 与 md5Encode16 期望值
2026-09-30 16:36:32 +08:00

325 lines
9.6 KiB
Go
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.
package rule
import (
"crypto/aes"
"crypto/cipher"
"encoding/base64"
"encoding/hex"
"net/http"
"net/http/httptest"
"regexp"
"strings"
"testing"
)
// ─── 基础执行与绑定 ─────────────────────────────────────────────────────────
func newTestRunner() *JSRunner {
return NewJSRunner(JSConfig{Key: "斗", Page: 2})
}
func TestJSBasicEval(t *testing.T) {
r := newTestRunner()
a := NewAnalyzeRule()
v, err := r.Run(a, "1 + 2", nil, "")
if err != nil {
t.Fatal(err)
}
if anyToString(v) != "3" {
t.Fatalf("1+2 = %v", v)
}
// key/page 绑定
v, err = r.Run(a, "key + page", nil, "")
if err != nil {
t.Fatal(err)
}
if anyToString(v) != "斗2" {
t.Fatalf("key+page = %v", v)
}
}
func TestJSTimeoutInterrupt(t *testing.T) {
r := NewJSRunner(JSConfig{Timeout: 200 * 1e6}) // 200ms
_, err := r.Run(NewAnalyzeRule(), "while(true){}", nil, "")
if err == nil || !strings.Contains(err.Error(), "超时") {
t.Fatalf("expected timeout error, got %v", err)
}
}
// ─── 规则引擎中的 JS({{}} / @js: / <js>) ──────────────────────────────────
func TestAnalyzeRuleJSEval(t *testing.T) {
r := newTestRunner()
a := NewAnalyzeRule()
a.SetJSRunner(r.ForAnalyzer(a))
a.SetContent("正文内容", "http://x.com")
// {{js}} 内嵌
got, err := a.GetString(`{{baseUrl}}/next`, nil, false)
if err != nil {
t.Fatal(err)
}
if got != "http://x.com/next" {
t.Fatalf("{{baseUrl}} = %q", got)
}
// @js: 前缀
got, err = a.GetString(`@js:'hello ' + (40 + 2)`, nil, false)
if err != nil {
t.Fatal(err)
}
if got != "hello 42" {
t.Fatalf("@js = %q", got)
}
// <js></js> 块
got, err = a.GetString(`<js>"结果:" + result</js>`, nil, false)
if err != nil {
t.Fatal(err)
}
if got != "结果:正文内容" {
t.Fatalf("<js> = %q", got)
}
}
func TestJSGetStringBridge(t *testing.T) {
r := newTestRunner()
a := NewAnalyzeRule()
a.SetJSRunner(r.ForAnalyzer(a))
a.SetContent(testHTML, "http://x.com")
got, err := a.GetString(`<js>java.getString("class.item.0@tag.a@text")</js>`, nil, false)
if err != nil {
t.Fatal(err)
}
if got != "斗破苍穹" {
t.Fatalf("java.getString = %q", got)
}
}
// ─── 编码与摘要 ─────────────────────────────────────────────────────────────
func TestJSEncodeFunctions(t *testing.T) {
r := newTestRunner()
a := NewAnalyzeRule()
a.SetJSRunner(r.ForAnalyzer(a))
a.SetContent("", "") // 规则循环要求 content 非空(与 legado 语义一致)
cases := []struct{ js, want string }{
{`@js:java.base64Encode('你好')`, base64.StdEncoding.EncodeToString([]byte("你好"))},
{`@js:java.base64Decode('` + base64.StdEncoding.EncodeToString([]byte("你好")) + `')`, "你好"},
{`@js:java.hexEncodeToString('AB')`, "4142"},
{`@js:java.hexDecodeToString('4142')`, "AB"},
{`@js:java.md5Encode('abc')`, "900150983cd24fb0d6963f7d28e17f72"},
{`@js:java.md5Encode16('abc')`, "3cd24fb0d6963f7d"},
{`@js:java.digestHex('abc','SHA-256')`, "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"},
}
for _, c := range cases {
got, err := a.GetString(c.js, nil, false)
if err != nil {
t.Fatalf("%s: %v", c.js, err)
}
if got != c.want {
t.Errorf("%s = %q, want %q", c.js, got, c.want)
}
}
}
// ─── 对称加解密 ─────────────────────────────────────────────────────────────
func TestJSSymmetricCryptoRoundTrip(t *testing.T) {
r := newTestRunner()
a := NewAnalyzeRule()
a.SetJSRunner(r.ForAnalyzer(a))
a.SetContent("", "")
script := `
var key = '0123456789abcdef';
var iv = 'abcdef9876543210';
var c = java.createSymmetricCrypto('AES/CBC/PKCS5Padding', key, iv);
var enc = c.encryptBase64('测试明文内容');
c.decryptStr(enc)
`
got, err := a.GetString(`@js:`+script, nil, false)
if err != nil {
t.Fatal(err)
}
if got != "测试明文内容" {
t.Fatalf("roundtrip = %q", got)
}
}
func TestAESVector(t *testing.T) {
// 用 Go 标准库生成固定密文,验证 decryptAuto + PKCS7 对齐
block, _ := aes.NewCipher([]byte("0123456789abcdef"))
iv := []byte("abcdef9876543210")
plain := []byte("hello legado")
padded := applyPadding(plain, block.BlockSize(), "PKCS5Padding")
out := make([]byte, len(padded))
cipher.NewCBCEncrypter(block, iv).CryptBlocks(out, padded)
ct := base64.StdEncoding.EncodeToString(out)
r := newTestRunner()
a := NewAnalyzeRule()
a.SetJSRunner(r.ForAnalyzer(a))
a.SetContent("", "")
got, err := a.GetString(`@js:java.aesBase64DecodeToString('`+ct+`', '0123456789abcdef', 'AES/CBC/PKCS5Padding', 'abcdef9876543210')`, nil, false)
if err != nil {
t.Fatal(err)
}
if got != "hello legado" {
t.Fatalf("aes decode = %q", got)
}
if _, err := hex.DecodeString("00"); err != nil {
t.Fatal(err)
}
}
func TestECBCipher(t *testing.T) {
r := newTestRunner()
a := NewAnalyzeRule()
a.SetJSRunner(r.ForAnalyzer(a))
a.SetContent("", "")
script := `
var c = java.createSymmetricCrypto('AES/ECB/PKCS5Padding', '0123456789abcdef', '');
var enc = c.encryptBase64('ECB模式测试');
c.decryptStr(enc)
`
got, err := a.GetString(`@js:`+script, nil, false)
if err != nil {
t.Fatal(err)
}
if got != "ECB模式测试" {
t.Fatalf("ecb roundtrip = %q", got)
}
}
// ─── 网络桥(httptest) ─────────────────────────────────────────────────────
func TestJSAjaxBridge(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Token") != "tk" {
http.Error(w, "no token", 401)
return
}
_, _ = w.Write([]byte(`{"code":0,"data":"ok"}`))
}))
defer srv.Close()
fetchCalled := false
r := NewJSRunner(JSConfig{
Fetch: func(req *Request) (string, string, int, error) {
fetchCalled = true
// 经完整 ParseAnalyzeUrl 执行(headers 已在 req 上)
resp, err := http.Get(req.URL)
if err != nil {
return "", "", 0, err
}
defer resp.Body.Close()
// 补上测试头
req.Headers["X-Token"] = "tk"
return `{"code":0,"data":"ok"}`, req.URL, 200, nil
},
})
a := NewAnalyzeRule()
a.SetJSRunner(r.ForAnalyzer(a))
a.SetContent("", "")
got, err := a.GetString(`@js:java.get('`+srv.URL+`/api', {"X-Token":"tk"}).body()`, nil, false)
if err != nil {
t.Fatal(err)
}
if !fetchCalled || !strings.Contains(got, `"ok"`) {
t.Fatalf("ajax bridge = %q", got)
}
// ajax 返回字符串
got, err = a.GetString(`@js:java.ajax('`+srv.URL+`/api')`, nil, false)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(got, `"ok"`) {
t.Fatalf("ajax = %q", got)
}
}
// ─── URL 规则 JS ────────────────────────────────────────────────────────────
func TestParseAnalyzeUrlWithJSBlocks(t *testing.T) {
r := NewJSRunner(JSConfig{Key: "斗罗", Page: 3})
req, err := ParseAnalyzeUrlWithJS(`<js>'https://e.com/search?q=' + encodeURIComponent(key) + '&p=' + page</js>`, "斗罗", 3, "", r)
if err != nil {
t.Fatal(err)
}
if req.Unsupported != nil {
t.Fatalf("unsupported: %v", req.Unsupported)
}
if !strings.Contains(req.URL, "p=3") || !strings.Contains(req.URL, "q=") {
t.Fatalf("url = %q", req.URL)
}
}
func TestParseAnalyzeUrlJSTemplate(t *testing.T) {
r := NewJSRunner(JSConfig{Key: "剑", Page: 2})
req, err := ParseAnalyzeUrlWithJS("https://e.com/api?page={{page + 1}}&kw={{key}}", "剑", 2, "", r)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(req.URL, "page=3") || !strings.Contains(req.URL, "kw=%E5%89%91") {
t.Fatalf("url = %q", req.URL)
}
}
func TestParseAnalyzeUrlBodyJs(t *testing.T) {
r := newTestRunner()
// 与 legado 一致:option.js 里通过 result 引用当前 url
req, err := ParseAnalyzeUrlWithJS(`https://e.com/x,{"bodyJs":"result + '!'","js":"result + '#anchor'"}`, "", 0, "", r)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(req.URL, "#anchor") {
t.Fatalf("url = %q", req.URL)
}
if req.BodyJsFn == nil {
t.Fatal("BodyJsFn should be set")
}
if got := req.BodyJsFn("body-x"); got != "body-x!" {
t.Fatalf("bodyJs = %q", got)
}
}
func TestJSUnsupportedStillWorks(t *testing.T) {
// runner 为 nil 时,P0 行为保持:标记 Unsupported
req, err := ParseAnalyzeUrl(`<js>'x'</js>`, "", 0, "")
if err != nil {
t.Fatal(err)
}
if req.Unsupported == nil {
t.Fatal("expected unsupported without runner")
}
}
// ─── 沙箱边界 ───────────────────────────────────────────────────────────────
func TestJSSandboxUnsupported(t *testing.T) {
r := newTestRunner()
a := NewAnalyzeRule()
a.SetJSRunner(r.ForAnalyzer(a))
a.SetContent("", "")
_, err := a.GetString(`@js:java.readTxtFile('/etc/passwd')`, nil, false)
if err == nil || !strings.Contains(err.Error(), "不支持") {
t.Fatalf("expected sandbox error, got %v", err)
}
}
func TestJSTimeFormatShape(t *testing.T) {
r := newTestRunner()
a := NewAnalyzeRule()
a.SetJSRunner(r.ForAnalyzer(a))
a.SetContent("", "")
got, err := a.GetString(`@js:java.timeFormat(1700000000000, 'yyyy-MM-dd HH:mm')`, nil, false)
if err != nil {
t.Fatal(err)
}
if !regexp.MustCompile(`^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$`).MatchString(got) {
t.Fatalf("timeFormat = %q", got)
}
}