Files
Cyrene_v2/internal/runtime/multimodal/vision/limits_test.go
T
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

138 lines
6.2 KiB
Go
Raw 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"
"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)
}
}