bug处理

This commit is contained in:
truewhile
2026-10-08 11:13:10 +08:00
parent 8bfdd75d47
commit 2b2dc697ce
14 changed files with 952 additions and 9 deletions
+26
View File
@@ -13,6 +13,7 @@ import (
"io"
"net/http"
"net/url"
"path/filepath"
"regexp"
"sort"
"strings"
@@ -404,6 +405,12 @@ func (s *ReaderService) executeWithState(ctx context.Context, req *rule.Request,
break
}
data = helper.DecompressBody(resp, data)
// Raw:按原始字节返回(书源文件落盘用),不做 charset 解码。
// 必须先于 HexBody 判断之外处理:GBK 文本一旦按 UTF-8 解码就变成 U+FFFD,
// 落盘的文件会坏掉。
if req.Raw {
return string(data), resp.Request.URL.String(), resp.StatusCode, nil
}
// 声明了 type 的请求按「原始字节的 hex」返回(对应 legado AnalyzeUrl.type),
// 不做 charset 解码——书源会自己 hexDecodeToString 取回内容。
if req.HexBody {
@@ -641,9 +648,28 @@ func (sess *sourceSession) runner(key string, page int) *rule.JSRunner {
JSLib: SPtr(sess.bs.JSLib),
Ctx: sess.ctx,
Browser: host,
// 书源文件缓存根目录:java.downloadFile / cacheFile 落盘用。
CacheDir: sess.svc.readerFileCacheDir(),
})
}
// readerFileCacheDir 书源文件缓存根目录(java.downloadFile / cacheFile)。
// 优先用配置的 cache_dir,退回 data_dir/cache;都不可用时返回空串,
// 此时文件类 JS 接口会抛出「未配置缓存目录」而不是写到进程当前目录。
func (s *ReaderService) readerFileCacheDir() string {
if s == nil || s.cfg == nil {
return ""
}
if base := strings.TrimSpace(s.cfg.Cache.CacheDir); base != "" {
return base
}
if dataDir := strings.TrimSpace(s.cfg.App.DataDir); dataDir != "" {
return filepath.Join(dataDir, "cache")
}
return ""
}
// srcID 书源记录 ID(登录界面待办按书源隔离)。
func (sess *sourceSession) srcID() string {
if sess.src != nil {
@@ -0,0 +1,121 @@
package reader
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"golang.org/x/text/encoding/simplifiedchinese"
)
// 本文件:拷贝系列轻小说源的端到端回归。
//
// 合成一个「照搬拷贝轻小说写法」的书源,一次覆盖四个修复点:
// 1. 搜索列表规则 `results.list.[*]`(legado 的 `.[*]` 形式);
// 2. 目录规则用 `new Packages.java.util.LinkedHashMap()/ArrayList()` 拼装;
// 3. 正文规则用 `java.cacheFile(url)` 取整卷文本;
// 4. 正文是 GBK 文本——必须走 Raw 原始字节通道落盘,否则会被 UTF-8 解码成乱码。
func TestCopyLightNovelSourceEndToEnd(t *testing.T) {
var srv *httptest.Server
gbkBody, err := simplifiedchinese.GBK.NewEncoder().Bytes([]byte("第一章 开始\n第二章 继续"))
if err != nil {
t.Fatal(err)
}
mux := http.NewServeMux()
// 搜索:Django REST 风格的 JSON。q_type/_update 参数照抄真实源。
mux.HandleFunc("/api/search", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":200,"results":{"list":[
{"name":"转生成为史莱姆","path_word":"slime","author":[{"name":"作者甲"}],"cover":"https://img.example.com/1.jpg"}
]}}`))
})
mux.HandleFunc("/api/book/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":200,"results":{"book":{"name":"转生成为史莱姆","path_word":"slime"}}}`))
})
mux.HandleFunc("/api/volumes", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":200,"results":{"list":[{"name":"第一卷","id":7}]}}`))
})
mux.HandleFunc("/api/volume/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprintf(w, `{"code":200,"results":{"volume":{"txt_addr":"%s/api/txt/7","txt_encoding":"GBK"}}}`, srv.URL)
})
mux.HandleFunc("/api/txt/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write(gbkBody)
})
srv = httptest.NewServer(mux)
defer srv.Close()
source := map[string]any{
"bookSourceName": "合成拷贝轻小说源",
"bookSourceUrl": srv.URL,
"enabled": true,
"enabledExplore": true,
"searchUrl": srv.URL + "/api/search?limit=20&q_type=&q={{key}}&_update=true",
"ruleSearch": map[string]any{
"bookList": "results.list.[*]",
"name": "$.name",
"author": "$.author[*].name",
"coverUrl": "$.cover",
"bookUrl": srv.URL + "/api/book/{{$.path_word}}?_update=true",
},
"ruleBookInfo": map[string]any{
"init": "$.results.book",
"name": "$.name",
"intro": "$.brief",
"tocUrl": srv.URL + "/api/volumes?book={{$.path_word}}&_update=true",
},
"ruleToc": map[string]any{
"chapterList": `<js>
function mk(n, u) { var m = new Packages.java.util.LinkedHashMap(); m.put('name', n); m.put('url', u); return m; }
var base = '` + srv.URL + `/api/volume/';
var out = new Packages.java.util.ArrayList();
var vols = JSON.parse(result).results.list;
for (var i = 0; i < vols.length; i++) { out.add(mk(vols[i].name, base + vols[i].id + '?_update=true')); }
out;</js>`,
"chapterName": "$.name",
"chapterUrl": "$.url",
},
"ruleContent": map[string]any{
"content": `@js:java.cacheFile(java.getString('$..txt_addr'))`,
},
}
raw, err := json.Marshal(source)
if err != nil {
t.Fatal(err)
}
svc, _ := newLoginTestService(t)
svc.cfg.Cache.CacheDir = t.TempDir()
res := svc.SmokeSource(t.Context(), string(raw), "史莱姆")
if !res.OK {
t.Fatalf("链路失败于 %s: %s\n%s", res.FailedAt, res.Error, joinLogs(res))
}
if res.SearchHits != 1 {
t.Fatalf("搜索命中=%d, want 1(`.[*]` 路径修复)", res.SearchHits)
}
if res.Chapters != 1 {
t.Fatalf("章节数=%d, want 1(Packages 修复)", res.Chapters)
}
if res.ContentLen == 0 {
t.Fatalf("正文长度=0(cacheFile/Raw 修复未生效)\n%s", joinLogs(res))
}
if !strings.Contains(joinLogs(res), "第一章") {
t.Fatalf("正文未正确解码(GBK 落盘被 utf-8 破坏)\n%s", joinLogs(res))
}
}
func joinLogs(res *SmokeChainResult) string {
var b strings.Builder
for _, l := range res.Logs {
fmt.Fprintf(&b, "[%s/%s] %s\n", l.Stage, l.Level, l.Message)
}
return b.String()
}
+95 -2
View File
@@ -10,6 +10,8 @@ import (
"encoding/hex"
"fmt"
"hash"
"os"
"path/filepath"
"strings"
"github.com/dop251/goja"
@@ -435,6 +437,95 @@ func newJavaObject(vm *goja.Runtime, r *JSRunner, a *AnalyzeRule) *goja.Object {
return goja.Null()
})
// ── 本地文件(对应 legado JsExtensions 的缓存/文件系列) ──
//
// 服务端把文件限制在自己的缓存目录(<cache>/reader/files)内,
// 书源只能用相对路径(允许以 / 开头,downloadFile 的返回值即此形态)。
set("downloadFile", func(call goja.FunctionCall) goja.Value {
// 旧签名 downloadFile(contentHex, url):把 hex 字符串落成文件。
if len(call.Arguments) >= 2 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
url := stringArg(call, 1)
raw, err := hex.DecodeString(strings.TrimSpace(stringArg(call, 0)))
if err != nil {
bridgeErr("downloadFile", err)
}
rel, err := writeCacheFile(r.cfg.CacheDir, cacheFileName(url), raw)
if err != nil {
bridgeErr("downloadFile", err)
}
return vm.ToValue(rel)
}
rel, err := downloadToCache(r, stringArg(call, 0))
if err != nil {
bridgeErr("downloadFile", err)
}
return vm.ToValue(rel)
})
// cacheFile(url): 下载并缓存文本文件,返回文件**内容**(对应 legado 语义)。
set("cacheFile", func(call goja.FunctionCall) goja.Value {
url := stringArg(call, 0)
if dir := readerFilesDir(r.cfg.CacheDir); dir != "" {
if b, err := os.ReadFile(filepath.Join(dir, cacheFileName(url))); err == nil && len(b) > 0 {
return vm.ToValue(decodeTextAuto(b))
}
}
rel, err := downloadToCache(r, url)
if err != nil {
bridgeErr("cacheFile", err)
}
full, err := resolveCacheFilePath(r.cfg.CacheDir, rel)
if err != nil {
bridgeErr("cacheFile", err)
}
b, err := os.ReadFile(full)
if err != nil {
bridgeErr("cacheFile", err)
}
return vm.ToValue(decodeTextAuto(b))
})
set("readFile", func(call goja.FunctionCall) goja.Value {
full, err := resolveCacheFilePath(r.cfg.CacheDir, stringArg(call, 0))
if err != nil {
return goja.Null()
}
b, err := os.ReadFile(full)
if err != nil {
return goja.Null()
}
return vm.ToValue(vm.NewArrayBuffer(b))
})
set("readTxtFile", func(call goja.FunctionCall) goja.Value {
full, err := resolveCacheFilePath(r.cfg.CacheDir, stringArg(call, 0))
if err != nil {
return vm.ToValue("")
}
b, err := os.ReadFile(full)
if err != nil {
return vm.ToValue("")
}
if cs := stringArgOr(call, 1, ""); cs != "" {
if s, derr := DecodeBytes(b, cs); derr == nil {
return vm.ToValue(s)
}
}
return vm.ToValue(decodeTextAuto(b))
})
set("deleteFile", func(call goja.FunctionCall) goja.Value {
full, err := resolveCacheFilePath(r.cfg.CacheDir, stringArg(call, 0))
if err != nil {
return vm.ToValue(false)
}
return vm.ToValue(os.Remove(full) == nil)
})
// getFile(path): 返回 File 对象(提供书源常用的 exists/length/name/delete/readText)。
set("getFile", func(call goja.FunctionCall) goja.Value {
full, err := resolveCacheFilePath(r.cfg.CacheDir, stringArg(call, 0))
if err != nil {
full = ""
}
return newFileObject(vm, full, stringArg(call, 0))
})
// ── 书源会话状态(legado 中 `java` 与 `source` 是同一对象) ──
if r.state != nil {
bindSourceState(vm, set, r.state, r.cfg.SourceProps)
@@ -540,13 +631,15 @@ func newJavaObject(vm *goja.Runtime, r *JSRunner, a *AnalyzeRule) *goja.Object {
set(name, unsupported(name, "需要无头浏览器(WebView)"))
}
for _, name := range []string{
"importScript", "cacheFile", "downloadFile", "readFile", "readTxtFile", "deleteFile",
"unzipFile", "un7zFile", "unrarFile", "unArchiveFile", "getTxtInFolder",
"getZipStringContent", "getZipByteArrayContent",
"getRarStringContent", "get7zStringContent",
} {
set(name, unsupported(name, "需要本地文件/压缩包能力"))
set(name, unsupported(name, "需要压缩包解压能力"))
}
// importScript 在 legado 里是「下载 JS 文件并 eval」,服务端可做但会引入
// 任意脚本执行面,暂不支持,保持明确报错。
set("importScript", unsupported("importScript", "服务端不支持动态加载外部脚本"))
return o
}
@@ -0,0 +1,209 @@
package rule
import (
"fmt"
"os"
"path"
"path/filepath"
"strings"
"unicode/utf8"
"github.com/dop251/goja"
)
// 本文件实现 legado JsExtensions 里的「本地文件」能力
// (cacheFile / downloadFile / readFile / readTxtFile / deleteFile / getFile)。
//
// 与 legado 的语义保持一致:
// - downloadFile(url) 下载到缓存目录,返回「相对缓存根目录」的路径;
// - cacheFile(url) 下载(带缓存)后返回文件的**文本内容**(不是路径);
// - readFile/readTxtFile/deleteFile/getFile 接受相对路径(允许以 / 开头),
// 一律解析到缓存目录下。
//
// 服务端把缓存根目录固定为 <cache>/reader/files,并且只允许访问该目录内的文件,
// 避免书源通过 `../../` 之类读到宿主上的任意文件。
// filesSubdir 书源文件缓存目录(相对 ReaderService 的缓存根目录)。
const filesSubdir = "reader/files"
// readerFilesDir 返回书源文件缓存目录;未配置缓存目录时返回空串。
func readerFilesDir(cacheDir string) string {
if strings.TrimSpace(cacheDir) == "" {
return ""
}
return filepath.Join(cacheDir, "reader", "files")
}
// cacheFileName 计算某个 URL 在缓存目录里的文件名(对应 legado 的
// `${md5Encode16(url)}.${type}`:md5 十六进制取中间 16 位,保留 URL 后缀)。
func cacheFileName(rawURL string) string {
name := md5Hex(rawURL, true)
if ext := urlFileSuffix(rawURL); ext != "" {
name += "." + ext
}
return name
}
// urlFileSuffix 取 URL 路径部分的后缀(不含 `.`),只接受字母数字(长度 1~8)。
func urlFileSuffix(rawURL string) string {
u := rawURL
if i := strings.IndexAny(u, "?#"); i >= 0 {
u = u[:i]
}
if i := strings.LastIndex(u, "/"); i >= 0 {
u = u[i+1:]
}
dot := strings.LastIndex(u, ".")
if dot < 0 || dot == len(u)-1 {
return ""
}
ext := u[dot+1:]
if len(ext) > 8 {
return ""
}
for i := 0; i < len(ext); i++ {
c := ext[i]
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9') {
return ""
}
}
return strings.ToLower(ext)
}
// resolveCacheFilePath 把书源给的相对路径解析成缓存目录下的绝对路径。
// 允许以 `/` 开头(downloadFile 的返回值就是这种形态),但拒绝越出缓存目录。
func resolveCacheFilePath(cacheDir, p string) (string, error) {
dir := readerFilesDir(cacheDir)
if dir == "" {
return "", fmt.Errorf("未配置缓存目录")
}
p = strings.TrimSpace(p)
if p == "" {
return "", fmt.Errorf("文件路径为空")
}
rel := filepath.Clean(filepath.FromSlash(strings.Trim(p, `/\`)))
if rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
return "", fmt.Errorf("非法文件路径: %s", p)
}
return filepath.Join(dir, rel), nil
}
// writeCacheFile 把字节写入缓存目录,返回相对路径(以 / 开头,对应 legado)。
func writeCacheFile(cacheDir, name string, data []byte) (string, error) {
dir := readerFilesDir(cacheDir)
if dir == "" {
return "", fmt.Errorf("未配置缓存目录")
}
if err := os.MkdirAll(dir, 0o750); err != nil {
return "", err
}
if err := os.WriteFile(filepath.Join(dir, name), data, 0o600); err != nil {
return "", err
}
return "/" + name, nil
}
// downloadToCache 下载 URL 并落到缓存目录(已存在则直接复用),
// 返回相对缓存根目录的路径。下载走书源会话,因此会带上书源请求头与 Cookie。
func downloadToCache(r *JSRunner, rawURL string) (string, error) {
dir := readerFilesDir(r.cfg.CacheDir)
if dir == "" {
return "", fmt.Errorf("未配置缓存目录")
}
name := cacheFileName(rawURL)
full := filepath.Join(dir, name)
if st, err := os.Stat(full); err == nil && st.Size() > 0 {
return "/" + name, nil
}
req, err := ParseAnalyzeUrlWithJS(rawURL, "", 0, r.cfg.BaseURL, r)
if err != nil {
return "", err
}
if req.Unsupported != nil {
return "", req.Unsupported
}
// Raw:响应按原始字节返回,避免 charset 解码破坏二进制/非 UTF-8 文本。
// 同时清掉 HexBody:URL 选项里的 `type` 在 legado 是文件后缀提示,
// 这里不该把下载内容按 hex 字符串落盘。
req.Raw = true
req.HexBody = false
body, _, code, err := r.fetch(req)
if err != nil {
return "", err
}
if code >= 400 {
return "", fmt.Errorf("下载失败 HTTP %d: %s", code, rawURL)
}
return writeCacheFile(r.cfg.CacheDir, name, []byte(body))
}
// decodeTextAuto 解码文本文件:UTF-8 BOM → 去 BOM;合法 UTF-8 → 原样;
// 否则按 GBK 解(中文站点/书源最常见的另一种编码),失败再原样返回。
func decodeTextAuto(b []byte) string {
if len(b) >= 3 && b[0] == 0xEF && b[1] == 0xBB && b[2] == 0xBF {
return string(b[3:])
}
if utf8.Valid(b) {
return string(b)
}
if s, err := DecodeBytes(b, "gbk"); err == nil && s != "" {
return s
}
return string(b)
}
// fileSuffixFromName 取路径的最后一段(getFile 的 name 语义)。
func fileSuffixFromName(p string) string {
return path.Base(strings.ReplaceAll(p, "\\", "/"))
}
// newFileObject 构造 `java.getFile(path)` 返回的 File 等价物,
// 提供书源常用的 exists / length / name / delete / readText。
func newFileObject(vm *goja.Runtime, full, raw string) *goja.Object {
o := vm.NewObject()
set := func(k string, v any) { _ = o.Set(k, v) }
set("path", full)
set("name", fileSuffixFromName(raw))
set("exists", func(goja.FunctionCall) goja.Value {
if full == "" {
return vm.ToValue(false)
}
_, err := os.Stat(full)
return vm.ToValue(err == nil)
})
set("isDirectory", func(goja.FunctionCall) goja.Value {
if full == "" {
return vm.ToValue(false)
}
st, err := os.Stat(full)
return vm.ToValue(err == nil && st.IsDir())
})
set("length", func(goja.FunctionCall) goja.Value {
if full == "" {
return vm.ToValue(0)
}
st, err := os.Stat(full)
if err != nil {
return vm.ToValue(0)
}
return vm.ToValue(st.Size())
})
set("delete", func(goja.FunctionCall) goja.Value {
if full == "" {
return vm.ToValue(false)
}
return vm.ToValue(os.Remove(full) == nil)
})
set("readText", func(goja.FunctionCall) goja.Value {
if full == "" {
return vm.ToValue("")
}
b, err := os.ReadFile(full)
if err != nil {
return vm.ToValue("")
}
return vm.ToValue(decodeTextAuto(b))
})
set("toString", func(goja.FunctionCall) goja.Value { return vm.ToValue(full) })
return o
}
+152
View File
@@ -0,0 +1,152 @@
package rule
import (
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"golang.org/x/text/encoding/simplifiedchinese"
)
// 本文件:java 文件接口(cacheFile / downloadFile / readTxtFile / deleteFile)
// 的语义回归。对应 legado JsExtensions:
// - cacheFile(url) → 返回文件**文本内容**(不是路径);
// - downloadFile(url) → 返回相对缓存根目录的路径;
// - readTxtFile(path[, charset]) → 返回文本;
// - 只允许访问缓存目录内的文件。
func newFileTestRunner(cacheDir, baseURL string, hits *int32) *JSRunner {
return NewJSRunner(JSConfig{
CacheDir: cacheDir,
BaseURL: baseURL,
Fetch: func(req *Request) (string, string, int, error) {
if hits != nil {
atomic.AddInt32(hits, 1)
}
resp, err := http.Get(req.URL) //nolint:gosec // 测试内固定 httptest 地址
if err != nil {
return "", "", 0, err
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return string(b), req.URL, resp.StatusCode, nil
},
})
}
func TestCacheFileReturnsTextContent(t *testing.T) {
var hits int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("第一章 开始\n第二章 继续"))
}))
defer srv.Close()
r := newFileTestRunner(t.TempDir(), srv.URL, &hits)
ar := NewAnalyzeRule()
v, err := r.Run(ar, "java.cacheFile('"+srv.URL+"/vol.txt')", nil, "")
if err != nil {
t.Fatalf("cacheFile 失败: %v", err)
}
if got := anyToString(v); got != "第一章 开始\n第二章 继续" {
t.Fatalf("cacheFile 应返回文本内容,实际 %q", got)
}
// 第二次应命中缓存,不再发起请求。
if _, err := r.Run(ar, "java.cacheFile('"+srv.URL+"/vol.txt')", nil, ""); err != nil {
t.Fatal(err)
}
if n := atomic.LoadInt32(&hits); n != 1 {
t.Fatalf("重复调用应命中缓存(请求次数=%d)", n)
}
}
func TestCacheFileDecodesGBK(t *testing.T) {
gbk, err := simplifiedchinese.GBK.NewEncoder().Bytes([]byte("中文内容测试"))
if err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(gbk)
}))
defer srv.Close()
r := newFileTestRunner(t.TempDir(), srv.URL, nil)
v, err := r.Run(NewAnalyzeRule(), "java.cacheFile('"+srv.URL+"/gbk.txt')", nil, "")
if err != nil {
t.Fatal(err)
}
if got := anyToString(v); got != "中文内容测试" {
t.Fatalf("GBK 文本未正确解码: %q", got)
}
}
func TestDownloadFileThenReadTxtFile(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("正文内容"))
}))
defer srv.Close()
cacheDir := t.TempDir()
r := newFileTestRunner(cacheDir, srv.URL, nil)
ar := NewAnalyzeRule()
rawPath, err := r.Run(ar, "java.downloadFile('"+srv.URL+"/a.txt')", nil, "")
if err != nil {
t.Fatal(err)
}
rel := anyToString(rawPath)
if !strings.HasPrefix(rel, "/") || !strings.HasSuffix(rel, ".txt") {
t.Fatalf("downloadFile 应返回相对缓存路径,实际 %q", rel)
}
if _, err := os.Stat(filepath.Join(cacheDir, "reader", "files", strings.TrimPrefix(rel, "/"))); err != nil {
t.Fatalf("文件未落到缓存目录: %v", err)
}
v, err := r.Run(ar, "java.readTxtFile('"+rel+"')", nil, "")
if err != nil {
t.Fatal(err)
}
if got := anyToString(v); got != "正文内容" {
t.Fatalf("readTxtFile = %q", got)
}
del, err := r.Run(ar, "String(java.deleteFile('"+rel+"'))", nil, "")
if err != nil {
t.Fatal(err)
}
if anyToString(del) != "true" {
t.Fatalf("deleteFile 应返回 true,实际 %q", anyToString(del))
}
}
func TestFileOpsRejectPathTraversal(t *testing.T) {
cacheDir := t.TempDir()
secret := filepath.Join(cacheDir, "secret.txt")
if err := os.WriteFile(secret, []byte("不该被读到"), 0o600); err != nil {
t.Fatal(err)
}
r := newFileTestRunner(cacheDir, "", nil)
ar := NewAnalyzeRule()
for _, p := range []string{"/../../secret.txt", "../secret.txt", "/../reader/files/../../secret.txt"} {
v, err := r.Run(ar, "java.readTxtFile('"+p+"')", nil, "")
if err != nil {
t.Fatalf("%s 不应报错: %v", p, err)
}
if got := anyToString(v); strings.Contains(got, "不该被读到") {
t.Fatalf("越权读到了缓存目录外的文件(%s)", p)
}
}
}
func TestFileOpsWithoutCacheDirFail(t *testing.T) {
r := NewJSRunner(JSConfig{})
_, err := r.Run(NewAnalyzeRule(), "java.cacheFile('https://example.com/a.txt')", nil, "")
if err == nil || !strings.Contains(err.Error(), "缓存目录") {
t.Fatalf("未配置缓存目录时应明确报错,实际 %v", err)
}
}
+121 -1
View File
@@ -72,6 +72,9 @@ type JSConfig struct {
// Browser 宿主浏览器实现(java.startBrowser / startBrowserAwait)。
// nil 时这两个函数抛出不支持错误。
Browser BrowserHost
// CacheDir 书源文件缓存根目录(java.downloadFile / cacheFile 落盘用)。
// 为空时这些函数抛出明确错误。
CacheDir string
// Ctx 本次执行的可取消上下文,透传给 BrowserHost 的等待。
Ctx context.Context
}
@@ -211,11 +214,100 @@ func NewJSRunner(cfg JSConfig) *JSRunner {
vm.Set("source", srcObj)
// java 也要在 jsLib 之前就位(jsLib 顶层可能引用 java.*)。
r.installJava(nil)
// Packages(Java 类命名空间)同样要在 jsLib 之前注入:不少书源在 jsLib 或
// 目录/正文规则里用 `new Packages.java.util.LinkedHashMap()` 这类写法。
r.installPackages()
// 部分源把 source 的方法也当 java 成员用(同一 Kotlin 对象暴露两份)。
r.loadJSLib()
return r
}
// installPackages 注入 Java 风格类命名空间(Packages.java.util.*)。
//
// goja 是纯 JS 运行时,没有 Java。书源(尤其照搬 Java 教程写的)常用
// `new Packages.java.util.ArrayList()` / `LinkedHashMap` / `HashMap` 来拼
// 目录列表和请求头,缺了它整条规则直接 ReferenceError(拷贝漫画轻小说源的
// 目录规则就是这么写的)。
//
// 实现要点:容器用「真正的 JS 数组 / 对象」,方法用 Object.defineProperty 定义为
// 不可枚举属性。这样容器交给引擎侧(GetElements/GetString)时导出的是干净的
// 数组([]any)或映射(map[string]any),而不会把 add/put 这些方法也当成元素或
// 请求头带出去。
func (r *JSRunner) installPackages() {
if _, err := r.vm.RunString(packagesPreamble); err != nil {
// 注入失败不影响其它规则,只是 Packages.* 不可用。
r.jsLibErr = fmt.Errorf("Packages 环境初始化失败: %w", err)
}
}
// packagesPreamble 定义全局 Packages(以及与之等价的 java.util / java.lang 常用类)。
const packagesPreamble = `
var Packages = (function () {
function def(o, k, v) {
Object.defineProperty(o, k, { value: v, enumerable: false, writable: true, configurable: true });
}
function makeMap() {
var m = {};
def(m, 'put', function (k, v) { m[k] = v; return v; });
def(m, 'putAll', function (o) { if (o) { for (var k in o) { m[k] = o[k]; } } });
def(m, 'get', function (k) { return m[k] === undefined ? null : m[k]; });
def(m, 'getOrDefault', function (k, d) { return m[k] === undefined ? d : m[k]; });
def(m, 'containsKey', function (k) { return Object.prototype.hasOwnProperty.call(m, k); });
def(m, 'containsValue', function (v) { for (var k in m) { if (m[k] === v) { return true; } } return false; });
def(m, 'remove', function (k) { var v = m[k]; delete m[k]; return v; });
def(m, 'size', function () { return Object.keys(m).length; });
def(m, 'isEmpty', function () { return Object.keys(m).length === 0; });
def(m, 'clear', function () { for (var k in m) { delete m[k]; } });
def(m, 'keySet', function () { return Object.keys(m); });
def(m, 'values', function () { var a = []; for (var k in m) { a.push(m[k]); } return a; });
def(m, 'entrySet', function () { var a = []; for (var k in m) { a.push({ key: k, value: m[k] }); } return a; });
def(m, 'toString', function () { return JSON.stringify(m); });
return m;
}
function makeList() {
var a = [];
def(a, 'add', function (x) { a.push(x); return true; });
def(a, 'addAll', function (xs) { if (xs) { for (var i = 0; i < xs.length; i++) { a.push(xs[i]); } } return true; });
def(a, 'get', function (i) { return a[i]; });
def(a, 'size', function () { return a.length; });
def(a, 'isEmpty', function () { return a.length === 0; });
def(a, 'contains', function (x) { return a.indexOf(x) >= 0; });
def(a, 'remove', function (i) { return a.splice(i, 1)[0]; });
def(a, 'clear', function () { a.length = 0; });
def(a, 'toArray', function () { return a.slice(); });
def(a, 'toString', function () { return a.join(','); });
return a;
}
var util = {};
var mapNames = ['LinkedHashMap', 'HashMap', 'TreeMap', 'Hashtable', 'ConcurrentHashMap', 'LinkedTreeMap'];
for (var i = 0; i < mapNames.length; i++) { util[mapNames[i]] = makeMap; }
var listNames = ['ArrayList', 'LinkedList', 'Vector'];
for (var j = 0; j < listNames.length; j++) { util[listNames[j]] = makeList; }
util.HashSet = makeList;
util.Arrays = { asList: function () { return Array.prototype.slice.call(arguments); } };
util.Collections = {
emptyList: function () { return []; },
emptyMap: function () { return makeMap(); },
singletonList: function (x) { return [x]; }
};
var lang = {
String: function (v) { return v == null ? '' : String(v); },
Integer: function (v) { return parseInt(v, 10) || 0; },
Long: function (v) { return parseInt(v, 10) || 0; },
Double: function (v) { return parseFloat(v) || 0; },
Boolean: function (v) { return !!v; },
StringBuilder: function () {
var s = '';
var o = {};
def(o, 'append', function (x) { s += (x == null ? '' : x); return o; });
def(o, 'toString', function () { return s; });
return o;
}
};
return { java: { util: util, lang: lang } };
})();
`
// installJava 安装/刷新本次执行可见的 java 对象(桥回指定解析器)。
func (r *JSRunner) installJava(a *AnalyzeRule) {
r.vm.Set("java", newJavaObject(r.vm, r, a))
@@ -399,7 +491,7 @@ func (r *JSRunner) Run(a *AnalyzeRule, js string, result any, baseURL string) (a
}
vm.Set("nextChapterUrl", nil)
prog, err := compileCached(scopedRuleJS(js))
prog, err := compileRuleJS(js)
if err != nil {
return nil, fmt.Errorf("JS 编译失败: %w", err)
}
@@ -441,6 +533,31 @@ func scopedRuleJS(js string) string {
return "{\n" + js + "\n}"
}
// functionRuleJS 把规则 JS 包成函数体后求值。
//
// legado 允许书源规则用顶层 `return` 提前返回(拷贝漫画轻小说源的正文规则
// 就在 if 分支里 `return '<img ...>'`),而 JS 脚本语法不允许顶层 return,
// 只有函数体允许。这里作为块形式的兜底。
func functionRuleJS(js string) string {
return "(function(){\n" + js + "\n})()"
}
// compileRuleJS 编译一段书源规则 JS。
//
// 优先用「块」形式:块的完成值就是最后一条语句的值,大量书源依赖它
// (如 `@js:1+2` 直接产出 3),且块作用域能隔离顶层 let/const(见 scopedRuleJS)。
// 块形式编译失败时退回「函数体」形式,兼容 legado 允许的顶层 return。
func compileRuleJS(js string) (*goja.Program, error) {
prog, err := compileCached(scopedRuleJS(js))
if err == nil {
return prog, nil
}
if prog2, err2 := compileCached(functionRuleJS(js)); err2 == nil {
return prog2, nil
}
return nil, err // 两种形式都编译不过,返回块形式的错误(更贴近书源原文)
}
// exportValue 把 JS 返回值转为 Go 值(字符串/数值/映射/切片)。
func exportValue(v goja.Value) any {
if v == nil || goja.IsUndefined(v) || goja.IsNull(v) {
@@ -495,6 +612,9 @@ func newResponseObject(vm *goja.Runtime, body string, code int, finalURL string,
}
mustSet("body", func(call goja.FunctionCall) goja.Value { return vm.ToValue(body) })
mustSet("bodyStr", body)
// bodyAsBytes:部分书源用 `java.bytesToStr(resp.bodyAsBytes(), enc)` 处理
// 非 UTF-8(GBK)正文(拷贝漫画轻小说源即如此)。
mustSet("bodyAsBytes", func(call goja.FunctionCall) goja.Value { return vm.ToValue(vm.NewArrayBuffer([]byte(body))) })
mustSet("code", func(call goja.FunctionCall) goja.Value { return vm.ToValue(code) })
mustSet("url", func(call goja.FunctionCall) goja.Value { return vm.ToValue(finalURL) })
mustSet("header", func(call goja.FunctionCall) goja.Value {
+40 -3
View File
@@ -332,14 +332,51 @@ func TestJSUnsupportedStillWorks(t *testing.T) {
// ─── 沙箱边界 ───────────────────────────────────────────────────────────────
// TestJSSandboxUnsupported 服务端不接受书源读取宿主任意文件。
//
// 文件类接口(cacheFile/downloadFile/readTxtFile/...)已实现,但路径被限制在
// 书源自己的缓存目录内:越权路径安全地返回空,而不是把 /etc/passwd 交出去,
// 也不应因为越权路径抛异常(否则书源会误判为「线路故障」反复重试)。
// 真正无实现的能力(解压包、无头浏览器等)继续明确抛「不支持」。
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)
got, err := a.GetString(`@js:java.readTxtFile('/etc/passwd')`, nil, false)
if err != nil {
t.Fatalf("越权路径应安全返回空而不是抛异常: %v", err)
}
if strings.Contains(got, "root:") || strings.Contains(got, "/bin/") {
t.Fatalf("沙箱被突破,读到了宿主文件: %q", got)
}
for _, call := range []string{`java.unzipFile('/tmp/a.zip')`, `java.webView('x')`} {
if _, err := a.GetString(`@js:`+call, nil, false); err == nil || !strings.Contains(err.Error(), "不支持") {
t.Fatalf("%s 应继续抛「不支持」,实际 %v", call, err)
}
}
}
// TestRuleJSTopLevelReturn legado 允许规则 JS 顶层 return;无 return 时仍取最后
// 一条语句的值(`@js:1+2` → 3)。
func TestRuleJSTopLevelReturn(t *testing.T) {
r := NewJSRunner(JSConfig{})
ar := NewAnalyzeRule()
v, err := r.Run(ar, "if (1) { return '早退'; }\nreturn '兜底';", nil, "")
if err != nil {
t.Fatalf("顶层 return 应可用: %v", err)
}
if got := anyToString(v); got != "早退" {
t.Fatalf("top-level return = %q, want 早退", got)
}
v2, err := r.Run(ar, "1 + 2", nil, "")
if err != nil {
t.Fatal(err)
}
if got := anyToString(v2); got != "3" {
t.Fatalf("最后一条语句的值 = %q, want 3", got)
}
}
+41 -1
View File
@@ -32,13 +32,53 @@ func jsonRead(root any, path string) any {
if root == nil || path == "" {
return nil
}
v, err := jsonpathGet(path, root)
v, err := jsonpathGet(normalizeJSONPath(path), root)
if err != nil {
return nil
}
return v
}
// normalizeJSONPath 把 legado(Jayway)允许、而 PaesslerAG/jsonpath 拒绝的写法
// 归一化成合法路径,共两处差异:
//
// 1. 缺根 `$`:Jayway 要求路径以 `$`/`@` 开头,legado 书源却常直接写
// `results.list`、`results.list.[*]`。PaesslerAG 对「不含 `[]`/`*` 的裸路径」
// 恰好能容忍(内部按 `$.` 解析),但一旦出现 `[*]` 就报
// `unexpected "*" while scanning extensions`。这里统一补根。
// 2. 点后紧跟方括号:`results.list.[*]`、`$.a.[0]`、`$.a.['k']`。Jayway 里
// `.[` 等价于 `[`,PaesslerAG 会报 `unexpected "[" while scanning field`。
//
// 两处都会让整条规则静默返回空——表现为「书源明明有效却搜不出任何结果」
// (拷贝轻小说源的搜索列表规则就是 `results.list.[*]`)。
func normalizeJSONPath(path string) string {
p := strings.TrimSpace(path)
if p == "" {
return path
}
switch {
case strings.HasPrefix(p, "$"), strings.HasPrefix(p, "@"):
// 已有根,保持
case strings.HasPrefix(p, "."):
p = "$" + p // `..a` 这类递归写法,补 `$` 而不是 `$.`
default:
p = "$." + p
}
if !strings.Contains(p, ".[") {
return p
}
var b strings.Builder
b.Grow(len(p))
for i := 0; i < len(p); i++ {
// 丢掉 `[` 前多余的点;递归下降 `..[` 保持原样。
if p[i] == '.' && i+1 < len(p) && p[i+1] == '[' && i > 0 && p[i-1] != '.' {
continue
}
b.WriteByte(p[i])
}
return b.String()
}
// jsonValueToString 对应 Kotlin 的 ob.toString() / joinToString("\n")。
// 列表按 "\n" 拼接(legado 的 getString 就是这么做的);对象走 Java 的 Map.toString
// 形态(见 javaValueString),书源的正则大量依赖这个形态。
@@ -0,0 +1,110 @@
package rule
import (
"testing"
)
// 本文件:legado 兼容性回归——`Packages.java.util.*` 容器与 `.[*]` 形式的 JSONPath。
//
// 背景(拷贝系列轻小说源在 MeBox 搜不到/读不了的根因):
// 1. 搜索列表规则写成 `results.list.[*]`(legado/Jayway 接受),PaesslerAG 直接
// 解析报错返回空;
// 2. 目录规则用 `new Packages.java.util.LinkedHashMap()` / `ArrayList()` 拼装,
// goja 里没有 Java,整条规则 ReferenceError。
// TestNormalizeJSONPathDotBracket 点后跟方括号应归一化成合法路径。
func TestNormalizeJSONPathDotBracket(t *testing.T) {
cases := map[string]string{
"results.list.[*]": "$.results.list[*]",
"$.results.list.[*]": "$.results.list[*]",
"$.a.[0].b": "$.a[0].b",
"$.a.['k']": "$.a['k']",
"$.results.list": "$.results.list",
"$.results..list[*]": "$.results..list[*]",
"results.list": "$.results.list",
"$[0].name": "$[0].name",
"..list[*]": "$..list[*]",
}
for in, want := range cases {
if got := normalizeJSONPath(in); got != want {
t.Errorf("normalizeJSONPath(%q) = %q, want %q", in, got, want)
}
}
}
// TestGetElementsDotBracketStarJsonPath 真实书源的 `results.list.[*]` 必须能取到列表。
func TestGetElementsDotBracketStarJsonPath(t *testing.T) {
const body = `{"results":{"list":[{"name":"甲"},{"name":"乙"}]}}`
for _, rule := range []string{"results.list.[*]", "$.results.list", "results.list"} {
ar := NewAnalyzeRule()
ar.SetContent(body, "")
els, err := ar.GetElements(rule)
if err != nil {
t.Fatalf("%s: %v", rule, err)
}
if len(els) != 2 {
t.Fatalf("%s: elements=%d, want 2", rule, len(els))
}
name, err := ar.GetString("$.name", els[0], false)
if err != nil || name != "甲" {
t.Fatalf("%s: first name=%q err=%v", rule, name, err)
}
}
}
// TestPackagesUtilCollections Packages.java.util 的 List/Map 应能拼出目录列表,
// 且导出给引擎的是干净的数组/映射(方法不能混进元素里)。
func TestPackagesUtilCollections(t *testing.T) {
r := NewJSRunner(JSConfig{})
if err := r.JSLibErr(); err != nil {
t.Fatalf("Packages 环境初始化失败: %v", err)
}
ar := NewAnalyzeRule()
ar.SetContent(`{"results":{"list":[{"name":"第一卷","id":7}]}}`, "https://api.example.com/volumes")
ar.SetJSRunner(r.ForAnalyzer(ar))
js := `<js>
function mk(n, u) { var m = new Packages.java.util.LinkedHashMap(); m.put('name', n); m.put('url', u); return m; }
var base = 'https://api.example.com/volume/';
var out = new Packages.java.util.ArrayList();
var vols = JSON.parse(result).results.list;
for (var i = 0; i < vols.length; i++) { out.add(mk(vols[i].name, base + vols[i].id)); }
out;</js>`
els, err := ar.GetElements(js)
if err != nil {
t.Fatalf("GetElements: %v", err)
}
if len(els) != 1 {
t.Fatalf("elements=%d, want 1", len(els))
}
if m, ok := els[0].(map[string]any); ok {
if _, hasMethod := m["put"]; hasMethod {
t.Fatalf("容器方法泄漏进了元素: %#v", m)
}
}
name, err := ar.GetString("$.name", els[0], false)
if err != nil || name != "第一卷" {
t.Fatalf("name=%q err=%v", name, err)
}
url, _ := ar.GetString("$.url", els[0], false)
if url != "https://api.example.com/volume/7" {
t.Fatalf("url=%q", url)
}
}
// TestPackagesHashMapUsableAsHeaders java.get(url, headers) 的 headers 用
// Packages.java.util.HashMap 构造时,导出结果应是干净的键值映射。
func TestPackagesHashMapUsableAsHeaders(t *testing.T) {
r := NewJSRunner(JSConfig{})
v, err := r.Run(NewAnalyzeRule(), `(function () {
var h = new Packages.java.util.HashMap();
h.put('X-Test', 'v1');
return JSON.stringify(h);
})()`, nil, "")
if err != nil {
t.Fatal(err)
}
if s := anyToString(v); s != `{"X-Test":"v1"}` {
t.Fatalf("HashMap 导出含额外键: %s", s)
}
}
+4
View File
@@ -43,6 +43,10 @@ type Request struct {
// 返回而不是解码成文本(对应 legado AnalyzeUrl.type)。
// 书源用它配合 data: 地址当参数信封,见 datauri.go。
HexBody bool
// Raw 请求响应按「原始字节」返回,跳过 charset 解码。
// 供 java.downloadFile / java.cacheFile 这类需要保真落盘的调用使用:
// 一旦按 UTF-8 解码,GBK 字节会被替换成 U+FFFD,写出来的文件就坏了。
Raw bool
// BodyJsFn 对应 UrlOption.bodyJs:响应体二次处理(JS 执行闭包)。
BodyJsFn func(body string) string
// Unsupported 非 nil 表示该请求依赖当前阶段不支持的能力,