根因(从 checkpoints 取到的那一轮 tool_call 实际 ARGS 逐字节核实):
模型交给工具的 image 参数是 "data:image/png;base64," —— 只有前缀、
载荷为空(全长 22 字符)。两个加载器早就会剥 data: 前缀,但把空载荷
解码成零字节后照样挂上合法 MIME 放行:
image-crop → 零字节进 image.Decode ⇒ image: unknown format
vision → DataURI("image/png", nil) 发给上游 ⇒ 400 unsupported image
(模型复述不出入参里那条 58583 字符的 data: URI,只抄下了前缀。)
- 新增 checkInlinePayload:data: URI 解码后做内容嗅探(复用既有裁决)
- 空载荷 / 缺 ;base64 段 / 缺逗号 / 非法 base64 ⇒ 明确拒绝
- 报错含「不是图片」+ 嗅到的类型 + 支持格式 + 十六进制物证 + 可行动提示,
且不回显正文或 base64
- 裸 base64、http(s) URL、工具 schema、安全策略一律未动
- 测试:新增 16 个用例;全树 61 包 ok;4 个变异(含停用嗅探 ⇒ 5 用例红)
251 lines
9.0 KiB
Go
251 lines
9.0 KiB
Go
package vision
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/base64"
|
||
"errors"
|
||
"image"
|
||
"image/color"
|
||
"image/jpeg"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// jpegBytes 返回一张真实的 JPEG(1x1),与 mime_test.go 的 pngBytes 同用途:
|
||
// 让「内容嗅探」在测试里有真实依据,而不是手写常量。
|
||
func jpegBytes(t *testing.T) []byte {
|
||
t.Helper()
|
||
img := image.NewRGBA(image.Rect(0, 0, 2, 2))
|
||
img.Set(0, 0, color.RGBA{R: 0xcc, G: 0x33, B: 0x33, A: 0xff})
|
||
var buf bytes.Buffer
|
||
if err := jpeg.Encode(&buf, img, nil); err != nil {
|
||
t.Fatalf("encode jpeg: %v", err)
|
||
}
|
||
return buf.Bytes()
|
||
}
|
||
|
||
// htmlDataURI 构造「把网页当图片传」的入参:声明 text/html,载荷是一段 HTML。
|
||
func htmlDataURI() string {
|
||
return "data:text/html;base64," + base64.StdEncoding.EncodeToString(htmlBytes())
|
||
}
|
||
|
||
// TestResolve_DataURIImagesDecode 钉住本缺陷的主用例:带完整 base64 载荷的
|
||
// data: URI(jpeg / png)必须照常解出字节与声明的 MIME —— 也就是 2026-10-07
|
||
// 那次失败里「本该成立的形状」。MIME 取前缀声明,字节逐字节不变。
|
||
func TestResolve_DataURIImagesDecode(t *testing.T) {
|
||
t.Parallel()
|
||
cases := []struct {
|
||
name string
|
||
mime string
|
||
data []byte
|
||
}{
|
||
{name: "jpeg", mime: "image/jpeg", data: jpegBytes(t)},
|
||
{name: "png", mime: "image/png", data: pngBytes(t)},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
src := DataURI(tc.mime, tc.data)
|
||
ref, err := ParseImageRef(src)
|
||
if err != nil {
|
||
t.Fatalf("ParseImageRef: %v", err)
|
||
}
|
||
mime, data, err := Resolve(context.Background(), ref, nil)
|
||
if err != nil {
|
||
t.Fatalf("Resolve(%s): %v", tc.mime, err)
|
||
}
|
||
if mime != tc.mime {
|
||
t.Errorf("mime = %q, want %q", mime, tc.mime)
|
||
}
|
||
if !bytes.Equal(data, tc.data) {
|
||
t.Errorf("bytes = %d, want the %d decoded bytes", len(data), len(tc.data))
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestResolve_EmptyDataURIPayloadRejected 是本缺陷的回归用例(2026-10-07
|
||
// 18:32:51 实测):模型传的是 `data:image/png;base64,` —— **只有前缀、没有载荷**。
|
||
// 旧代码解出零字节却照样报 image/png,空载荷被当成图片发给上游,换回一句
|
||
// 「You have uploaded an unsupported image」。现在必须在本地就明确否决,
|
||
// 并且说清病因(载荷为空)而不是甩一句 upstream 400。
|
||
func TestResolve_EmptyDataURIPayloadRejected(t *testing.T) {
|
||
t.Parallel()
|
||
ref, err := ParseImageRef("data:image/png;base64,")
|
||
if err != nil {
|
||
t.Fatalf("ParseImageRef: %v", err)
|
||
}
|
||
mime, data, err := Resolve(context.Background(), ref, nil)
|
||
if err == nil {
|
||
t.Fatalf("空载荷被接受(mime=%q, %d bytes),want 明确报错", mime, len(data))
|
||
}
|
||
if !errors.Is(err, ErrNotImage) {
|
||
t.Fatalf("error = %v, want ErrNotImage", err)
|
||
}
|
||
if !strings.Contains(err.Error(), "empty") {
|
||
t.Errorf("错误文案没点出「载荷为空」:%v", err)
|
||
}
|
||
if !strings.Contains(err.Error(), "http(s) URL") {
|
||
t.Errorf("错误文案没给可行动提示:%v", err)
|
||
}
|
||
}
|
||
|
||
// TestResolve_NonImageDataURIRejected 钉住「这不是图片」:声明 text/html 的
|
||
// data: URI 必须报明确错误,写清嗅探到的类型与支持的格式,且**不得回显正文**。
|
||
func TestResolve_NonImageDataURIRejected(t *testing.T) {
|
||
t.Parallel()
|
||
ref, err := ParseImageRef(htmlDataURI())
|
||
if err != nil {
|
||
t.Fatalf("ParseImageRef: %v", err)
|
||
}
|
||
if _, _, err := Resolve(context.Background(), ref, nil); err == nil {
|
||
t.Fatal("text/html 载荷被接受,want ErrNotImage")
|
||
} else {
|
||
if !errors.Is(err, ErrNotImage) {
|
||
t.Fatalf("error = %v, want ErrNotImage", err)
|
||
}
|
||
msg := err.Error()
|
||
if strings.Contains(msg, "502 Bad Gateway") || strings.Contains(msg, "<!DOCTYPE") {
|
||
t.Fatalf("错误文案回显了正文:%v", msg)
|
||
}
|
||
for _, want := range []string{"text/html", "png", "jpeg", "gif", "webp"} {
|
||
if !strings.Contains(msg, want) {
|
||
t.Errorf("错误文案缺少 %q:%v", want, msg)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestResolve_MalformedDataURI 钉住畸形 data: URI 不 panic、报可行动错误:
|
||
// 缺逗号分隔符、缺 ;base64 段、非法 base64 三种都要落到明确的说法上。
|
||
func TestResolve_MalformedDataURI(t *testing.T) {
|
||
t.Parallel()
|
||
cases := []struct {
|
||
name string
|
||
src string
|
||
}{
|
||
{name: "no comma", src: "data:image/jpeg;base64"},
|
||
{name: "no base64 marker", src: "data:image/png,plaintext"},
|
||
// 这一条专治「漏了 ;base64 段、但载荷恰好是合法 base64 字符表」:
|
||
// 旧 splitDataURI 会把它当 base64 解出 3 个零字节,还挂着 image/png
|
||
// 的假声明放行(百分号编码的 data: URI 从来不是本包的输入)。
|
||
{name: "no base64 marker with base64-shaped payload", src: "data:image/png,AAAA"},
|
||
{name: "illegal base64", src: "data:image/png;base64,!!!!"},
|
||
{name: "empty after prefix", src: "data:image/jpeg;base64,"},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
ref, err := ParseImageRef(tc.src)
|
||
if err != nil {
|
||
t.Fatalf("ParseImageRef: %v", err)
|
||
}
|
||
if _, _, err := Resolve(context.Background(), ref, nil); err == nil {
|
||
t.Fatalf("%q 被接受,want 报错", tc.src)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestResolve_BareBase64Unchanged 钉住「裸 base64 路径一个字不改」:它没有声明
|
||
// 可对照,包内契约定的是按 image/png 处理(见 Resolve 的文档)。合法的 png 与
|
||
// jpeg 裸载荷都必须照原样解出、MIME 仍是 image/png。
|
||
func TestResolve_BareBase64Unchanged(t *testing.T) {
|
||
t.Parallel()
|
||
for _, raw := range [][]byte{pngBytes(t), jpegBytes(t)} {
|
||
src := base64.StdEncoding.EncodeToString(raw)
|
||
ref, err := ParseImageRef(src)
|
||
if err != nil {
|
||
t.Fatalf("ParseImageRef: %v", err)
|
||
}
|
||
if ref.Source != SourceBase64 {
|
||
t.Fatalf("Source = %q, want base64", ref.Source)
|
||
}
|
||
mime, data, err := Resolve(context.Background(), ref, nil)
|
||
if err != nil {
|
||
t.Fatalf("裸 base64 被拒:%v", err)
|
||
}
|
||
if mime != "image/png" {
|
||
t.Errorf("mime = %q, want image/png(裸 base64 的既有契约)", mime)
|
||
}
|
||
if !bytes.Equal(data, raw) {
|
||
t.Errorf("裸 base64 解码结果变了:%d bytes", len(data))
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestVisionTool_DataURIReachesUpstreamIntact 是端到端的那一条(工具 → 适配器
|
||
// → 上游桩):合法的 data:image/jpeg;base64,... 必须原样成为线上 image_url 里的
|
||
// 合法 image 块 —— 上游收到的就是一张真 JPEG,所以不会再有 400。
|
||
func TestVisionTool_DataURIReachesUpstreamIntact(t *testing.T) {
|
||
t.Parallel()
|
||
jpg := jpegBytes(t)
|
||
src := DataURI("image/jpeg", jpg)
|
||
upSrv, rec := newImageURLRecorderServer(t)
|
||
tool := NewVisionTool("vision", newAdapter(upSrv.URL))
|
||
|
||
res, err := tool.Execute(context.Background(), map[string]any{"image": src, "prompt": "what is this"})
|
||
if err != nil {
|
||
t.Fatalf("Execute: %v", err)
|
||
}
|
||
if m, _ := res.(map[string]any); m["description"] == nil {
|
||
t.Fatalf("tool result = %#v, want a description", res)
|
||
}
|
||
calls, urls := rec.seen()
|
||
if calls != 1 || len(urls) != 1 {
|
||
t.Fatalf("upstream calls = %d urls = %d, want exactly one image block", calls, len(urls))
|
||
}
|
||
if urls[0] != src {
|
||
t.Fatalf("upstream image_url = %.40q..., want the input data URI byte for byte", urls[0])
|
||
}
|
||
payload := urls[0][strings.IndexByte(urls[0], ',')+1:]
|
||
raw, err := base64.StdEncoding.DecodeString(payload)
|
||
if err != nil {
|
||
t.Fatalf("上游收到的载荷不是合法 base64:%v", err)
|
||
}
|
||
if _, err := jpeg.Decode(bytes.NewReader(raw)); err != nil {
|
||
t.Fatalf("上游收到的载荷解不出 JPEG(就是那句 unsupported image 的成因):%v", err)
|
||
}
|
||
}
|
||
|
||
// TestVisionTool_EmptyPayloadNeverReachesUpstream 钉住失败发生在**本地**:
|
||
// 空载荷的 data: URI 必须在调用上游之前就被拒(旧行为是照发空图,换回 400)。
|
||
func TestVisionTool_EmptyPayloadNeverReachesUpstream(t *testing.T) {
|
||
t.Parallel()
|
||
upSrv, rec := newImageURLRecorderServer(t)
|
||
tool := NewVisionTool("vision", newAdapter(upSrv.URL))
|
||
|
||
_, err := tool.Execute(context.Background(), map[string]any{"image": "data:image/png;base64,"})
|
||
if err == nil {
|
||
t.Fatal("空载荷被接受,want 报错")
|
||
}
|
||
if !errors.Is(err, ErrNotImage) {
|
||
t.Fatalf("error = %v, want ErrNotImage", err)
|
||
}
|
||
if calls, _ := rec.seen(); calls != 0 {
|
||
t.Fatalf("上游被调用了 %d 次,want 0(缺陷正是「空图照发」)", calls)
|
||
}
|
||
}
|
||
|
||
// TestResolve_ErrorMessageLeaksNoPayload 钉住错误文案的边界:给模型的提示只含
|
||
// 类型级信息与固定建议,不含 base64 载荷或图片正文(载荷可能很大也可能敏感)。
|
||
func TestResolve_ErrorMessageLeaksNoPayload(t *testing.T) {
|
||
t.Parallel()
|
||
payload := base64.StdEncoding.EncodeToString(htmlBytes())
|
||
ref, err := ParseImageRef("data:image/png;base64," + payload)
|
||
if err != nil {
|
||
t.Fatalf("ParseImageRef: %v", err)
|
||
}
|
||
mime, data, err := Resolve(context.Background(), ref, nil)
|
||
if err == nil {
|
||
t.Fatalf("谎报 image/png 的 HTML 载荷被接受(mime=%q, %d bytes)", mime, len(data))
|
||
}
|
||
if strings.Contains(err.Error(), payload) {
|
||
t.Fatalf("错误文案回显了 base64 载荷:%v", err)
|
||
}
|
||
if !strings.Contains(err.Error(), "text/html") {
|
||
t.Errorf("错误文案没点出嗅探到的真实类型:%v", err)
|
||
}
|
||
}
|