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

349 lines
12 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 io
import (
"bytes"
"context"
"encoding/base64"
"image"
"image/color"
"image/jpeg"
"image/png"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// gradientPNG writes an image whose pixel (x,y) encodes its own coordinates in the
// red/green channels. That makes a crop self-verifying: if the crop's top-left
// pixel is not RGBA{sourceX, sourceY, 0, 255} the wrong region was returned.
func gradientPNG(t *testing.T, w, h int) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
img.Set(x, y, color.RGBA{R: uint8(x), G: uint8(y), A: 255})
}
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatalf("png.Encode: %v", err)
}
return buf.Bytes()
}
// decodeCropResult pulls the PNG out of a crop tool result and decodes it.
func decodeCropResult(t *testing.T, result any) image.Image {
t.Helper()
m, ok := result.(map[string]any)
if !ok {
t.Fatalf("result type = %T, want map[string]any", result)
}
uri, _ := m["image"].(string)
if !strings.HasPrefix(uri, "data:image/png;base64,") {
t.Fatalf("image field = %q, want a PNG data URI", uri)
}
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(uri, "data:image/png;base64,"))
if err != nil {
t.Fatalf("decode data URI: %v", err)
}
img, _, err := image.Decode(bytes.NewReader(raw))
if err != nil {
t.Fatalf("decode crop: %v", err)
}
return img
}
func newCropToolForTest(t *testing.T, extra ...Option) (*cropTool, string) {
t.Helper()
dir := t.TempDir()
set := buildTools(t, &config.Config{DataDir: dir}, extra...)
tool, ok := findTool(t, set, "image-crop").(*cropTool)
if !ok {
t.Fatalf("image-crop tool has type %T, want *cropTool", findTool(t, set, "image-crop"))
}
return tool, dir
}
// TestCropTool_CropsRequestedRegion pins the region arithmetic: the returned crop
// must be exactly the requested region with the requested pixels, not merely the
// right size (裁决 14.4.5③).
func TestCropTool_CropsRequestedRegion(t *testing.T) {
t.Parallel()
tool, dir := newCropToolForTest(t)
path := filepath.Join(dir, "src.png")
if err := os.WriteFile(path, gradientPNG(t, 100, 80), filePerm); err != nil {
t.Fatalf("write source: %v", err)
}
res, err := tool.Execute(context.Background(), map[string]any{
"image": path, "x": 10, "y": 20, "width": 30, "height": 40,
})
if err != nil {
t.Fatalf("Execute: %v", err)
}
img := decodeCropResult(t, res)
if img.Bounds().Dx() != 30 || img.Bounds().Dy() != 40 {
t.Fatalf("crop size = %dx%d, want 30x40", img.Bounds().Dx(), img.Bounds().Dy())
}
got := color.NRGBAModel.Convert(img.At(0, 0)).(color.NRGBA)
if got.R != 10 || got.G != 20 {
t.Fatalf("top-left pixel = %v, want the source pixel (10,20)", got)
}
last := color.NRGBAModel.Convert(img.At(29, 39)).(color.NRGBA)
if last.R != 39 || last.G != 59 {
t.Fatalf("bottom-right pixel = %v, want the source pixel (39,59)", last)
}
}
// TestCropTool_RejectsRegionOutsideImage pins fail-loud instead of clamping: a
// clamp would hand the model a different region than it asked for.
func TestCropTool_RejectsRegionOutsideImage(t *testing.T) {
t.Parallel()
tool, dir := newCropToolForTest(t)
path := filepath.Join(dir, "src.png")
if err := os.WriteFile(path, gradientPNG(t, 50, 50), filePerm); err != nil {
t.Fatalf("write source: %v", err)
}
cases := []map[string]any{
{"image": path, "x": 40, "y": 0, "width": 20, "height": 10},
{"image": path, "x": 0, "y": 40, "width": 10, "height": 20},
{"image": path, "x": -1, "y": 0, "width": 10, "height": 10},
{"image": path, "x": 0, "y": 0, "width": 0, "height": 10},
{"image": path, "x": 0, "y": 0, "width": 10, "height": -5},
}
for _, args := range cases {
if _, err := tool.Execute(context.Background(), args); err == nil {
t.Errorf("args %v accepted, want an out-of-bounds error", args)
}
}
}
// TestCropTool_RejectsPathOutsideSandbox pins the file sandbox reuse: cropping can
// never reach a file the file tool could not read.
func TestCropTool_RejectsPathOutsideSandbox(t *testing.T) {
t.Parallel()
tool, _ := newCropToolForTest(t)
outside := filepath.Join(t.TempDir(), "outside.png")
if err := os.WriteFile(outside, gradientPNG(t, 10, 10), filePerm); err != nil {
t.Fatalf("write outside image: %v", err)
}
if _, err := tool.Execute(context.Background(), map[string]any{
"image": outside, "x": 0, "y": 0, "width": 2, "height": 2,
}); err == nil {
t.Fatal("path outside the data dir accepted, want a sandbox rejection")
}
}
// TestCropTool_AcceptsDataURIAndURL pins the other two sources: a data: URI and an
// http(s) URL both decode into a crop.
func TestCropTool_AcceptsDataURIAndURL(t *testing.T) {
t.Parallel()
src := gradientPNG(t, 20, 20)
uri := "data:image/png;base64," + base64.StdEncoding.EncodeToString(src)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write(src)
}))
t.Cleanup(srv.Close)
tool, _ := newCropToolForTest(t, WithAllowedHosts(serverHost(srv)))
cases := []string{uri, srv.URL + "/img.png"}
for _, source := range cases {
res, err := tool.Execute(context.Background(), map[string]any{
"image": source, "x": 5, "y": 5, "width": 4, "height": 3,
})
if err != nil {
t.Fatalf("Execute(%s): %v", source, err)
}
if img := decodeCropResult(t, res); img.Bounds().Dx() != 4 || img.Bounds().Dy() != 3 {
t.Fatalf("source %s crop size = %dx%d, want 4x3", source, img.Bounds().Dx(), img.Bounds().Dy())
}
}
}
// TestCropTool_RejectsUnsupportedDataURI pins a non-base64 data URI as an error
// rather than a silent empty decode.
func TestCropTool_RejectsUnsupportedDataURI(t *testing.T) {
t.Parallel()
tool, _ := newCropToolForTest(t)
if _, err := tool.Execute(context.Background(), map[string]any{
"image": "data:image/png,plaintext", "x": 0, "y": 0, "width": 1, "height": 1,
}); err == nil {
t.Fatal("non-base64 data URI accepted, want an error")
}
}
// gradientJPEG 与 gradientPNG 同构,供「data: URI 带真 JPEG 载荷」的用例使用。
// JPEG 有损,故用例只钉裁切尺寸,不逐像素比对。
func gradientJPEG(t *testing.T, w, h int) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
img.Set(x, y, color.RGBA{R: uint8(x * 2), G: uint8(y * 2), A: 255})
}
}
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, nil); err != nil {
t.Fatalf("jpeg.Encode: %v", err)
}
return buf.Bytes()
}
// cropArgs 拼一组 image-crop 的参数,省得每个用例重抄区域坐标。
func cropArgs(source string) map[string]any {
return map[string]any{"image": source, "x": 2, "y": 2, "width": 4, "height": 3}
}
// TestCropTool_DataURIRealImages 钉住主用例:带着**完整 base64 载荷**的
// data: URI(png 与 jpeg)都必须照常裁切 —— 也就是 2026-10-07 那次失败里
// 「本该成立的形状」。
func TestCropTool_DataURIRealImages(t *testing.T) {
t.Parallel()
tool, _ := newCropToolForTest(t)
sources := map[string]string{
"png": "data:image/png;base64," + base64.StdEncoding.EncodeToString(gradientPNG(t, 20, 20)),
"jpeg": "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(gradientJPEG(t, 20, 20)),
}
for name, source := range sources {
t.Run(name, func(t *testing.T) {
t.Parallel()
res, err := tool.Execute(context.Background(), cropArgs(source))
if err != nil {
t.Fatalf("Execute(%s data URI): %v", name, err)
}
if img := decodeCropResult(t, res); img.Bounds().Dx() != 4 || img.Bounds().Dy() != 3 {
t.Fatalf("crop size = %dx%d, want 4x3", img.Bounds().Dx(), img.Bounds().Dy())
}
})
}
}
// TestCropTool_RejectsEmptyDataURIPayload 是本缺陷的回归用例(2026-10-07
// 18:32:51 实测):模型传的是 `data:image/png;base64,` —— **只有前缀、没有载荷**。
// 旧路径把它解成零字节再报 `image: unknown format`,指不到真正的毛病。
// 现在必须在本地说清病因,并且**不许**再出现那句 unknown format。
func TestCropTool_RejectsEmptyDataURIPayload(t *testing.T) {
t.Parallel()
tool, _ := newCropToolForTest(t)
_, err := tool.Execute(context.Background(), cropArgs("data:image/png;base64,"))
if err == nil {
t.Fatal("空载荷的 data: URI 被接受,want 报错")
}
msg := err.Error()
if strings.Contains(msg, "unknown format") {
t.Fatalf("错误文案退回旧形状(unknown format):%v", msg)
}
if !strings.Contains(msg, "no payload") {
t.Errorf("错误文案没点出「载荷为空」:%v", msg)
}
if !strings.Contains(msg, "http(s) URL") {
t.Errorf("错误文案没给可行动提示:%v", msg)
}
}
// TestCropTool_RejectsNonImageAsNotAnImage 钉住「这不是图片」:把 HTML 当图片
// 传(无论走 data: URI 还是 URL)都要报明确错误 —— 说清嗅探到的类型、
// 支持的格式与可行动提示,且**不得回显正文**。
func TestCropTool_RejectsNonImageAsNotAnImage(t *testing.T) {
t.Parallel()
html := []byte("<!DOCTYPE html><html><body>502 Bad Gateway</body></html>")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(html)
}))
t.Cleanup(srv.Close)
tool, _ := newCropToolForTest(t, WithAllowedHosts(serverHost(srv)))
sources := map[string]string{
"data uri": "data:text/html;base64," + base64.StdEncoding.EncodeToString(html),
"url": srv.URL + "/page.html",
}
for name, source := range sources {
t.Run(name, func(t *testing.T) {
t.Parallel()
_, err := tool.Execute(context.Background(), cropArgs(source))
if err == nil {
t.Fatal("HTML 被当成图片接受,want 报错")
}
msg := err.Error()
if strings.Contains(msg, "502 Bad Gateway") || strings.Contains(msg, "<!DOCTYPE") {
t.Fatalf("错误文案回显了正文:%v", msg)
}
for _, want := range []string{"not an image", "text/html", "png", "jpeg", "gif"} {
if !strings.Contains(msg, want) {
t.Errorf("错误文案缺少 %q:%v", want, msg)
}
}
})
}
}
// TestCropTool_MalformedDataURI 钉住畸形入参不 panic、报可行动错误。
func TestCropTool_MalformedDataURI(t *testing.T) {
t.Parallel()
tool, _ := newCropToolForTest(t)
cases := map[string]string{
"no comma": "data:image/jpeg;base64",
"no base64": "data:image/png,plaintext",
"illegal base64": "data:image/png;base64,!!!!",
"empty payload": "data:image/jpeg;base64,",
}
for name, source := range cases {
t.Run(name, func(t *testing.T) {
t.Parallel()
if _, err := tool.Execute(context.Background(), cropArgs(source)); err == nil {
t.Fatalf("%q 被接受,want 报错", source)
}
})
}
}
// TestCropTool_BareBase64StaysAPath 钉住既有分类不变:裸 base64 **不是**本工具
// 支持的输入(cropSourceKindOf 只认 data: / http(s) / 路径),它照旧走文件路径那条
// 分支并被沙箱拒绝 —— 这次改动不碰它。
func TestCropTool_BareBase64StaysAPath(t *testing.T) {
t.Parallel()
tool, _ := newCropToolForTest(t)
bare := base64.StdEncoding.EncodeToString(gradientPNG(t, 8, 8))
_, err := tool.Execute(context.Background(), cropArgs(bare))
if err == nil {
t.Fatal("裸 base64 被当成图片接受,want 走路径分支并被拒")
}
if strings.Contains(err.Error(), "not an image") {
t.Fatalf("裸 base64 的失败形状变了(应仍是路径/沙箱报错):%v", err)
}
}
// TestCropTool_RegisteredInSet pins that the tool ships with the default set and
// advertises a usable schema (the agent only sees it if it is registered).
func TestCropTool_RegisteredInSet(t *testing.T) {
t.Parallel()
tool, _ := newCropToolForTest(t)
if tool.Name() != "image-crop" {
t.Errorf("Name = %q, want image-crop", tool.Name())
}
params, err := tool.Parameters()
if err != nil {
t.Fatalf("Parameters: %v", err)
}
if params["type"] != "object" {
t.Errorf("schema type = %v, want object", params["type"])
}
var _ sdk.Tool = tool
// A nil/mis-typed argument set must be an error, not a panic.
if _, err := tool.Execute(context.Background(), "not-a-map"); err == nil {
t.Error("non-object args accepted, want an error")
}
if _, err := tool.Execute(context.Background(), map[string]any{}); err == nil {
t.Error("missing image accepted, want an error")
}
}