package vision import ( "context" "errors" "path/filepath" "strings" "sync/atomic" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" ) // uploadTestName 是一个形状合法的上传文件名(32 位小写十六进制 + .png)。 const uploadTestName = "0123456789abcdef0123456789abcdef.png" // TestLocalUploadPath 是上传 URL → 本地路径的路径穿越防护用例表: // 只有严格形状才被接受,任何分隔符/点号段/编码变体都在触碰文件系统前被拒。 func TestLocalUploadPath(t *testing.T) { t.Parallel() dir := filepath.Join("tmp", "uploads") tests := []struct { name string rawURL string dir string want string wantHit bool }{ {name: "http upload url", rawURL: "http://192.168.1.5:10942/api/v1/files/" + uploadTestName, dir: dir, want: filepath.Join(dir, uploadTestName), wantHit: true}, {name: "https upload url", rawURL: "https://box.local/api/v1/files/" + uploadTestName, dir: dir, want: filepath.Join(dir, uploadTestName), wantHit: true}, {name: "traversal", rawURL: "http://h/api/v1/files/../../../data/cyrene.db", dir: dir, wantHit: false}, {name: "encoded traversal", rawURL: "http://h/api/v1/files/%2e%2e%2fcyrene.db", dir: dir, wantHit: false}, {name: "backslash", rawURL: `http://h/api/v1/files/..\cyrene.db`, dir: dir, wantHit: false}, {name: "absolute path name", rawURL: "http://h/api/v1/files//etc/passwd", dir: dir, wantHit: false}, {name: "uppercase hex", rawURL: "http://h/api/v1/files/" + strings.ToUpper(uploadTestName), dir: dir, wantHit: false}, {name: "short stem", rawURL: "http://h/api/v1/files/abcdef.png", dir: dir, wantHit: false}, {name: "non hex stem", rawURL: "http://h/api/v1/files/0123456789abcdef0123456789abcdeg.png", dir: dir, wantHit: false}, {name: "html extension", rawURL: "http://h/api/v1/files/" + strings.TrimSuffix(uploadTestName, ".png") + ".html", dir: dir, wantHit: false}, {name: "svg extension", rawURL: "http://h/api/v1/files/" + strings.TrimSuffix(uploadTestName, ".png") + ".svg", dir: dir, wantHit: false}, {name: "extra segment", rawURL: "http://h/api/v1/files/sub/" + uploadTestName, dir: dir, wantHit: false}, {name: "query string", rawURL: "http://h/api/v1/files/" + uploadTestName + "?x=1", dir: dir, wantHit: false}, {name: "fragment", rawURL: "http://h/api/v1/files/" + uploadTestName + "#f", dir: dir, wantHit: false}, {name: "other route", rawURL: "http://h/api/v1/other/" + uploadTestName, dir: dir, wantHit: false}, {name: "ftp scheme", rawURL: "ftp://h/api/v1/files/" + uploadTestName, dir: dir, wantHit: false}, {name: "relative url", rawURL: "/api/v1/files/" + uploadTestName, dir: dir, wantHit: false}, {name: "remote without uploads dir", rawURL: "http://h/api/v1/files/" + uploadTestName, dir: "", wantHit: false}, {name: "empty name", rawURL: "http://h/api/v1/files/", dir: dir, wantHit: false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() got, ok := localUploadPath(tt.rawURL, tt.dir) if ok != tt.wantHit { t.Fatalf("localUploadPath(%q, %q) = (%q, %v), want hit=%v", tt.rawURL, tt.dir, got, ok, tt.wantHit) } if tt.wantHit && got != tt.want { t.Fatalf("path = %q, want %q", got, tt.want) } }) } } // TestOpenAICompatibleAdapter_ReadsOwnUploadLocally 覆盖同源 upload 产物的 // 快路径:URL 指向本服务的 /api/v1/files/ 时直接读盘,不发起网络 // 请求(URL 的 host 在此不可达,若走了 HTTP 必然失败/超时)。 func TestOpenAICompatibleAdapter_ReadsOwnUploadLocally(t *testing.T) { t.Parallel() png := pngBytes(t) dataDir := t.TempDir() uploads := filepath.Join(dataDir, "uploads") if err := writeFileBytesAll(uploads, uploadTestName, png); err != nil { t.Fatalf("seed upload: %v", err) } upSrv, rec := newImageURLRecorderServer(t) ad := newAdapter(upSrv.URL, WithUploadsDir(uploads)) // 127.0.0.1:1 上是没有服务的:一旦退回网络取回就会立刻失败。 srcURL := "http://127.0.0.1:1/api/v1/files/" + uploadTestName if _, err := ad.Describe(context.Background(), ImageRef{Source: SourceURL, Data: srcURL}, ""); err != nil { t.Fatalf("Describe via the local upload path: %v", err) } _, urls := rec.seen() if len(urls) != 1 || urls[0] != DataURI("image/png", png) { t.Fatalf("image_url = %q, want the local file inlined", urls) } } // TestOpenAICompatibleAdapter_MissingUploadFailsLoudly 钉住「认得的上传 URL // 但文件不在」不会偷偷退回网络:错误如实上报(不静默换成别的内容)。 func TestOpenAICompatibleAdapter_MissingUploadFailsLoudly(t *testing.T) { t.Parallel() uploads := filepath.Join(t.TempDir(), "uploads") upSrv, rec := newImageURLRecorderServer(t) ad := newAdapter(upSrv.URL, WithUploadsDir(uploads)) srcURL := "http://127.0.0.1:1/api/v1/files/" + uploadTestName _, err := ad.Describe(context.Background(), ImageRef{Source: SourceURL, Data: srcURL}, "") if err == nil { t.Fatal("Describe succeeded without the upload on disk, want an error") } if !errors.Is(err, ErrBadImage) { t.Fatalf("error = %v, want ErrBadImage", err) } if calls, _ := rec.seen(); calls != 0 { t.Fatalf("upstream was called %d times, want 0", calls) } } // TestOpenAICompatibleAdapter_TraversalNotReadLocally 是越界文件名的反证 // 用例:本地 secret.png 与远端返回的图内容不同,`../` 名称必须落到远端 // 取回(而不是被读成本地越界文件)。 func TestOpenAICompatibleAdapter_TraversalNotReadLocally(t *testing.T) { t.Parallel() local := pngBytes(t) remote := append([]byte{}, pngBytes(t)...) remote = append(remote, 0x00) // 内容不同即可,仍是合法 PNG 前缀 var hits atomic.Int32 dataDir := t.TempDir() uploads := filepath.Join(dataDir, "uploads") if err := writeFileBytesAll(uploads, uploadTestName, local); err != nil { t.Fatalf("seed uploads dir: %v", err) } if err := writeFileBytes(filepath.Join(dataDir, "secret.png"), local); err != nil { t.Fatalf("seed secret: %v", err) } imgSrv := newImageServer(t, remote, "image/png", &hits) upSrv, rec := newImageURLRecorderServer(t) ad := newAdapter(upSrv.URL, WithUploadsDir(uploads)) srcURL := imgSrv.URL + "/api/v1/files/../secret.png" if _, err := ad.Describe(context.Background(), ImageRef{Source: SourceURL, Data: srcURL}, ""); err != nil { t.Fatalf("Describe: %v", err) } _, urls := rec.seen() if len(urls) != 1 { t.Fatalf("image_url = %v, want one part", urls) } if urls[0] == DataURI("image/png", local) { t.Fatal("the ../ name was resolved to a local file outside uploads") } if urls[0] != DataURI("image/png", remote) { t.Fatalf("image_url = %q, want the remote payload", urls[0]) } if hits.Load() != 1 { t.Fatalf("source fetched %d times, want 1", hits.Load()) } } // TestWithUploadsDirIsOptional 钉住未配置上传目录时行为不变:上传形状的 // URL 仍按普通 URL 取回。 func TestWithUploadsDirIsOptional(t *testing.T) { t.Parallel() png := pngBytes(t) var hits atomic.Int32 imgSrv := newImageServer(t, png, "image/png", &hits) upSrv, rec := newImageURLRecorderServer(t) ad := NewOpenAICompatible(config.LLMConfig{BaseURL: upSrv.URL, Model: "vision-test"}) _, err := ad.Describe(context.Background(), ImageRef{ Source: SourceURL, Data: imgSrv.URL + "/api/v1/files/" + uploadTestName, }, "") if err != nil { t.Fatalf("Describe: %v", err) } _, urls := rec.seen() if len(urls) != 1 || urls[0] != DataURI("image/png", png) { t.Fatalf("image_url = %q, want the fetched payload", urls) } if hits.Load() != 1 { t.Fatalf("source fetched %d times, want 1", hits.Load()) } }