根因(从 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 用例红)
138 lines
6.2 KiB
Go
138 lines
6.2 KiB
Go
package vision
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/base64"
|
||
"errors"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// pngSignature 是 PNG 的 8 字节魔数(http.DetectContentType 的判据)。
|
||
// 体积口径类用例要在载荷头部带上它,才不会被内容嗅探当成文本否决。
|
||
var pngSignature = []byte("\x89PNG\r\n\x1a\n")
|
||
|
||
// TestInlineImageLengthBoundary 钉住长度预检的边界与错误文案:恰好等于
|
||
// 上限通过,多一个字符就报 ErrImageTooLarge。比较运算符一旦被挪位
|
||
// (>= / <),或口径被换成另一个数,这里立刻红。
|
||
func TestInlineImageLengthBoundary(t *testing.T) {
|
||
t.Parallel()
|
||
if err := checkInlineImageLength(strings.Repeat("A", MaxInlineImageChars)); err != nil {
|
||
t.Fatalf("恰好等于上限被拒:%v", err)
|
||
}
|
||
err := checkInlineImageLength(strings.Repeat("A", MaxInlineImageChars+1))
|
||
if !errors.Is(err, ErrImageTooLarge) {
|
||
t.Fatalf("error = %v, want ErrImageTooLarge", err)
|
||
}
|
||
msg := err.Error()
|
||
if !strings.Contains(msg, "上限") || !strings.Contains(msg, "699") {
|
||
t.Fatalf("文案没有带上限:%v", err)
|
||
}
|
||
if strings.Contains(msg, "AAAA") {
|
||
t.Fatalf("文案泄漏了载荷内容:%v", err)
|
||
}
|
||
}
|
||
|
||
// TestMaxInlineImageCharsMatchesByteBudget 钉住换算口径:字符上限换算回
|
||
// 解码字节必须装得下一张满额 5 MiB 图,余量只能是 data: 前缀那点字符,
|
||
// 不能顺带塞进第二张图 —— 与网关入口 checkChatImageSize 同一口径。
|
||
func TestMaxInlineImageCharsMatchesByteBudget(t *testing.T) {
|
||
t.Parallel()
|
||
if got := MaxInlineImageChars / 4 * 3; got < MaxInlineImageBytes {
|
||
t.Fatalf("字符上限只装得下 %d 字节,少于预算 %d", got, MaxInlineImageBytes)
|
||
}
|
||
if want := ((MaxInlineImageBytes+2)/3)*4 + InlineImagePrefixAllowance; MaxInlineImageChars != want {
|
||
t.Fatalf("MaxInlineImageChars = %d, want %d(base64 膨胀 4/3 + 前缀余量)", MaxInlineImageChars, want)
|
||
}
|
||
if MaxInlineImageChars > ((MaxInlineImageBytes+2)/3)*4+4096 {
|
||
t.Fatalf("余量过大(应只是 data: 前缀余量):%d", MaxInlineImageChars)
|
||
}
|
||
if MaxInlineImageBytes != 5<<20 {
|
||
t.Fatalf("MaxInlineImageBytes = %d, want 5 MiB(与上传/网关口徑同值)", MaxInlineImageBytes)
|
||
}
|
||
}
|
||
|
||
// TestDecodeBase64_RejectsOversizeBeforeDecoding 钉住「预检先于解码」:
|
||
// 用一段既超限又非法 base64 的载荷,错误必须是 ErrImageTooLarge 而不是
|
||
// ErrBadImage —— 后者只可能来自 base64.StdEncoding.DecodeString,
|
||
// 也就是「先按约 4/3 分配再报错」,正是要避免的那一步。
|
||
func TestDecodeBase64_RejectsOversizeBeforeDecoding(t *testing.T) {
|
||
t.Parallel()
|
||
oversize := strings.Repeat("!", MaxInlineImageChars+1)
|
||
if _, _, err := decodeBase64(oversize); !errors.Is(err, ErrImageTooLarge) {
|
||
t.Fatalf("裸载荷 error = %v, want ErrImageTooLarge(预检没有先于解码)", err)
|
||
}
|
||
if _, _, err := decodeBase64("data:image/png;base64," + oversize); !errors.Is(err, ErrImageTooLarge) {
|
||
t.Fatalf("data: URI error = %v, want ErrImageTooLarge", err)
|
||
}
|
||
}
|
||
|
||
// TestInlineImageSrc_RejectsOversizeDataURI 覆盖短路路径:data: URI 在
|
||
// 原样返回**之前**也要过预检(这条路径不解码,此前完全不看体积),
|
||
// 而恰好等于上限的合法串必须逐字节原样返回、不受影响。
|
||
func TestInlineImageSrc_RejectsOversizeDataURI(t *testing.T) {
|
||
t.Parallel()
|
||
prefix := "data:image/png;base64,"
|
||
oversize := prefix + strings.Repeat("A", MaxInlineImageChars)
|
||
if _, err := inlineImageSrc(context.Background(), oversize); !errors.Is(err, ErrImageTooLarge) {
|
||
t.Fatalf("error = %v, want ErrImageTooLarge", err)
|
||
}
|
||
|
||
exact := prefix + strings.Repeat("A", MaxInlineImageChars-len(prefix))
|
||
got, err := inlineImageSrc(context.Background(), exact)
|
||
if err != nil {
|
||
t.Fatalf("恰好等于上限的 data: URI 被拒:%v", err)
|
||
}
|
||
if got != exact {
|
||
t.Fatal("短路路径改动了原串(客户端 base64 路径必须逐字节不变)")
|
||
}
|
||
}
|
||
|
||
// TestDecodeBase64_ExactBudgetImagePasses 用一张**恰好 5 MiB** 的载荷把口径
|
||
// 从字符数真的换算回解码字节数:满额图片编码后的字符串必须仍在字符上限内
|
||
// (否则网关入口与解码层对「一张图能有多大」给出了两个答案),解码结果
|
||
// 逐字节一致,绝不能误拒 —— 这正是「恰好 = 上限通过」的字节侧半边。
|
||
//
|
||
// 载荷头部带上 PNG 签名:本用例量的是体积口径,但 data: URI 解码后要过内容嗅探
|
||
// (image.go 的 checkInlinePayload),纯填充字节会被嗅成 text/plain 而否决 ——
|
||
// 那测的就是另一件事了。字节总长不变,故字符侧的口径换算不受影响。
|
||
//
|
||
// 判定口径是**字符数**(与网关入口一致,含 64 字符的 data: 前缀余量),
|
||
// 因此多出几十字节的载荷仍可能落在余量内通过;字符侧的 +1 越界由
|
||
// TestInlineImageLengthBoundary 钉住,余量本身不超过 64 字符
|
||
// (TestMaxInlineImageCharsMatchesByteBudget)。
|
||
func TestDecodeBase64_ExactBudgetImagePasses(t *testing.T) {
|
||
t.Parallel()
|
||
filler := bytes.Repeat([]byte{0x5a}, MaxInlineImageBytes-len(pngSignature))
|
||
exact := append(append([]byte{}, pngSignature...), filler...)
|
||
dataURI := DataURI("image/png", exact)
|
||
if len(dataURI) > MaxInlineImageChars {
|
||
t.Fatalf("满额图片编码后 %d 字符,超过上限 %d(口径不一致)", len(dataURI), MaxInlineImageChars)
|
||
}
|
||
mime, data, err := decodeBase64(dataURI)
|
||
if err != nil {
|
||
t.Fatalf("满额图片被拒:%v", err)
|
||
}
|
||
if mime != "image/png" || !bytes.Equal(data, exact) {
|
||
t.Fatalf("满额图片解码结果不一致:mime=%q bytes=%d", mime, len(data))
|
||
}
|
||
}
|
||
|
||
// TestDecodeBase64_SmallPayloadUnchanged 钉住预检不伤正常载荷:一张小图的
|
||
// data: URI 解码后 MIME 与字节都不变。
|
||
func TestDecodeBase64_SmallPayloadUnchanged(t *testing.T) {
|
||
t.Parallel()
|
||
png := pngBytes(t)
|
||
mime, data, err := decodeBase64(DataURI("image/png", png))
|
||
if err != nil {
|
||
t.Fatalf("decodeBase64: %v", err)
|
||
}
|
||
if mime != "image/png" || !bytes.Equal(data, png) {
|
||
t.Fatalf("decodeBase64 = (%q, %d bytes), want (image/png, %d bytes)", mime, len(data), len(png))
|
||
}
|
||
if _, _, err := decodeBase64(base64.StdEncoding.EncodeToString(png)); err != nil {
|
||
t.Fatalf("裸 base64 被拒:%v", err)
|
||
}
|
||
}
|