Files
AskaEth 789e3af74e fix(multimodal): 视觉工具的空/畸形 data: URI 明确拒绝 + 入参嗅探与可行动报错
根因(从 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 用例红)
2026-10-07 18:50:45 +08:00

251 lines
9.0 KiB
Go
Raw Permalink 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 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)
}
}