From 789e3af74e20496c0092fb40b95ac78ad9f4584d Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 7 Oct 2026 18:50:45 +0800 Subject: [PATCH] =?UTF-8?q?fix(multimodal):=20=E8=A7=86=E8=A7=89=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E7=9A=84=E7=A9=BA/=E7=95=B8=E5=BD=A2=20data:=20URI=20?= =?UTF-8?q?=E6=98=8E=E7=A1=AE=E6=8B=92=E7=BB=9D=20+=20=E5=85=A5=E5=8F=82?= =?UTF-8?q?=E5=97=85=E6=8E=A2=E4=B8=8E=E5=8F=AF=E8=A1=8C=E5=8A=A8=E6=8A=A5?= =?UTF-8?q?=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因(从 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 用例红) --- internal/runtime/multimodal/vision/image.go | 55 ++-- .../runtime/multimodal/vision/limits_test.go | 11 +- internal/runtime/multimodal/vision/payload.go | 62 +++++ .../runtime/multimodal/vision/payload_test.go | 250 ++++++++++++++++++ .../runtime/multimodal/vision/vision_test.go | 12 +- plugins/tools/io/crop.go | 29 +- plugins/tools/io/crop_test.go | 143 ++++++++++ plugins/tools/io/image_bytes.go | 98 +++++++ 8 files changed, 636 insertions(+), 24 deletions(-) create mode 100644 internal/runtime/multimodal/vision/payload.go create mode 100644 internal/runtime/multimodal/vision/payload_test.go create mode 100644 plugins/tools/io/image_bytes.go diff --git a/internal/runtime/multimodal/vision/image.go b/internal/runtime/multimodal/vision/image.go index 57cdab9..c0e13de 100644 --- a/internal/runtime/multimodal/vision/image.go +++ b/internal/runtime/multimodal/vision/image.go @@ -34,7 +34,8 @@ func DataURI(mime string, data []byte) string { // Resolve 加载一个 ImageRef 对应的字节。路径与 base64 载荷在 // 本地读取;URL 用 http.Client 抓取(传入的 client,为 nil 时 // 用默认 client)。对 base64 输入,Data 字段原样作为 -// 载荷返回,MIME 类型从 data: 前缀推断。 +// 载荷返回,MIME 类型从 data: 前缀推断;data: 载荷解码后还要过 +// 内容嗅探(见 checkInlinePayload),不是图片一律否决。 func Resolve(ctx context.Context, ref ImageRef, client *http.Client) (mime string, data []byte, err error) { switch ref.Source { case SourcePath: @@ -70,23 +71,38 @@ func readFile(path string) (string, []byte, error) { // decodeBase64 返回 base64/data: 载荷解码后的字节及其 MIME。 // 长度预检先于解码与 data: 解析(见 checkInlineImageLength):超限载荷在 // 分配之前就被拦下,而不是先按约 4/3 分配再报错。 +// +// data: URI 解码后要过内容嗅探(checkInlinePayload):前缀里的声明与真实字节 +// 对不上时否决,绝不把「挂着 image/* 声明、内容却不是图片」的载荷转成 data: URI +// 交给上游(2026-10-07 的实测缺陷:`data:image/png;base64,` 只带前缀不带载荷, +// 旧代码照样报 image/png,空载荷被当成图片发给上游)。 +// +// 裸 base64 路径保持原状:它没有声明可对照,包内契约定的是「按 image/png 处理」 +// (见 Resolve 的文档),加嗅探就是改这条契约。 func decodeBase64(src string) (string, []byte, error) { if err := checkInlineImageLength(src); err != nil { return "", nil, err } mime, encoded, isDataURI := splitDataURI(src) - if isDataURI { - decoded, err := base64.StdEncoding.DecodeString(encoded) + if !isDataURI { + // 以 data: 开头却没解析出 (mime, payload) 只有一个成因:**畸形**的 + // data: URI(缺逗号分隔符,或没有 ;base64 段 —— 本包只处理 base64 载荷, + // 与 image-crop 同口径)。放它走下面的裸 base64 解码,报出来的是 + // base64 语法错误,指不到真正的毛病,故在此明确拒绝。 + if strings.HasPrefix(src, "data:") { + return "", nil, fmt.Errorf("vision: %w: malformed data: URI (want data:;base64,)", ErrBadImage) + } + decoded, err := base64.StdEncoding.DecodeString(src) if err != nil { return "", nil, fmt.Errorf("vision: %w: %v", ErrBadImage, err) } - return mime, decoded, nil + return "image/png", decoded, nil } - decoded, err := base64.StdEncoding.DecodeString(src) + decoded, err := base64.StdEncoding.DecodeString(encoded) if err != nil { return "", nil, fmt.Errorf("vision: %w: %v", ErrBadImage, err) } - return "image/png", decoded, nil + return checkInlinePayload(mime, decoded) } // fetchURL 通过 http(s) 下载一张图像;只允许 http/https 协议。 @@ -184,8 +200,14 @@ func inlineImage(ctx context.Context, src string) (inlinedImage, error) { return inlinedImage{URI: DataURI(mime, data), MIME: mime, Raw: data}, nil } -// splitDataURI 解析 "data:;base64," 前缀。src 不是 -// data: URI 时第二个返回值是 false。 +// splitDataURI 解析 "data:;base64," 前缀。src 不是 data: URI、 +// 或虽是 data: URI 但不带 ;base64 段时返回 false。 +// +// 只认 base64 载荷(与 image-crop 的 decodeCropDataURI 同口径):百分号编码的 +// data: URI 从来就不是本包的输入,把它当 base64 解码只会得到一段垃圾字节, +// 还挂着 image/png 的假声明送进模型 —— 这类串现在按畸形 data: URI 拒绝。 +// 返回的 MIME 按包内统一口径归一化(去参数、转小写,见 mediaType);声明缺失 +// (`data:;base64,…`)时给 image/png,与本函数历来的默认一致。 func splitDataURI(src string) (mime, payload string, ok bool) { if !strings.HasPrefix(src, "data:") { return "", "", false @@ -196,18 +218,15 @@ func splitDataURI(src string) (mime, payload string, ok bool) { return "", "", false } meta := rest[:comma] - payload = rest[comma+1:] - mime = "image/png" - if strings.HasPrefix(meta, ";base64") { - return mime, payload, true + idx := strings.Index(meta, ";base64") + if idx < 0 { + return "", "", false } - if idx := strings.Index(meta, ";base64"); idx >= 0 { - if t := strings.TrimSuffix(meta[:idx], ";base64"); t != "" { - mime = t - } - return mime, payload, true + mime = mediaType(meta[:idx]) + if mime == "" { + mime = "image/png" } - return mime, payload, true + return mime, rest[comma+1:], true } // fileExists 报告 path 是否存在于磁盘且为常规文件。 diff --git a/internal/runtime/multimodal/vision/limits_test.go b/internal/runtime/multimodal/vision/limits_test.go index 873a914..4122787 100644 --- a/internal/runtime/multimodal/vision/limits_test.go +++ b/internal/runtime/multimodal/vision/limits_test.go @@ -9,6 +9,10 @@ import ( "testing" ) +// pngSignature 是 PNG 的 8 字节魔数(http.DetectContentType 的判据)。 +// 体积口径类用例要在载荷头部带上它,才不会被内容嗅探当成文本否决。 +var pngSignature = []byte("\x89PNG\r\n\x1a\n") + // TestInlineImageLengthBoundary 钉住长度预检的边界与错误文案:恰好等于 // 上限通过,多一个字符就报 ErrImageTooLarge。比较运算符一旦被挪位 // (>= / <),或口径被换成另一个数,这里立刻红。 @@ -90,13 +94,18 @@ func TestInlineImageSrc_RejectsOversizeDataURI(t *testing.T) { // (否则网关入口与解码层对「一张图能有多大」给出了两个答案),解码结果 // 逐字节一致,绝不能误拒 —— 这正是「恰好 = 上限通过」的字节侧半边。 // +// 载荷头部带上 PNG 签名:本用例量的是体积口径,但 data: URI 解码后要过内容嗅探 +// (image.go 的 checkInlinePayload),纯填充字节会被嗅成 text/plain 而否决 —— +// 那测的就是另一件事了。字节总长不变,故字符侧的口径换算不受影响。 +// // 判定口径是**字符数**(与网关入口一致,含 64 字符的 data: 前缀余量), // 因此多出几十字节的载荷仍可能落在余量内通过;字符侧的 +1 越界由 // TestInlineImageLengthBoundary 钉住,余量本身不超过 64 字符 // (TestMaxInlineImageCharsMatchesByteBudget)。 func TestDecodeBase64_ExactBudgetImagePasses(t *testing.T) { t.Parallel() - exact := bytes.Repeat([]byte{0x5a}, MaxInlineImageBytes) + 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) diff --git a/internal/runtime/multimodal/vision/payload.go b/internal/runtime/multimodal/vision/payload.go new file mode 100644 index 0000000..b229e96 --- /dev/null +++ b/internal/runtime/multimodal/vision/payload.go @@ -0,0 +1,62 @@ +package vision + +import ( + "fmt" + "strings" +) + +// 本文件管的是 **data:/base64 入参**的内容嗅探与可行动报错 —— 与 mime.go 的 +// imageMIMEFor 是同一套裁决(真实字节是否决权、声明只作参考),只是换了入口: +// mime.go 管 HTTP 响应与文件读取,这里管「调用方把图片正文直接塞进参数」那条路。 +// +// 起因(2026-10-07 18:32:51 实测,客户端从后端逐字节取过证据):模型交给 +// vision 工具的 image 是 `data:image/png;base64,` —— **只有前缀、没有载荷**。 +// 旧代码把它 base64 解码得到零字节,却照样报 MIME=image/png,于是那串空载荷 +// 挂着 image/png 的声明白白发给上游,换回一句「You have uploaded an unsupported +// image」。报错指向不了真正的毛病(参数里根本没有图片字节),模型也无从自纠; +// image-crop 在同一秒也栽在同一处,报的是 `image: unknown format`。 +// 现在这类输入在本地就被明确否决,并附上「它看起来是什么」的物证与可行动提示。 + +// supportedFormats 是视觉上游接受的图像格式(与上游白名单同口径: +// webp / png / jpeg / gif)。写进错误文案,模型据此换格式重试。 +const supportedFormats = "webp, png, jpeg, gif" + +// reuseHint 是「把非图片当图片传」时的可行动提示。模型复述不出会话里那张图的 +// 几万字符 base64(实测就是把前缀抄了下来),正确做法是传它的 URL 或本地路径。 +const reuseHint = "pass the image's http(s) URL or local path instead of retyping a data: URI" + +// evidenceBytes 是报错时作为物证给出的头部字节数。 +const evidenceBytes = 16 + +// checkInlinePayload 校验 data: URI 解码后的载荷,返回应当声明的 MIME 与载荷 +// 本身。判据与路径/URL 来源完全一致(复用 imageMIMEFor):判出文本一律否决, +// 判不出图像而声明也立不住时同样否决 —— 绝不把一段不是图片的字节挂着 +// image/* 的声明送进视觉模型。 +// +// 返回的 MIME 取 data: 前缀里**声明**的那个,而不是嗅探结果:这条路径的既有 +// 契约是「客户端送来的 data: URI 逐字节不变地继续工作」 +// (url_test.go 的 TestOpenAICompatibleAdapter_Base64Unchanged),换掉声明 +// 就等于改客户端的线上形状;嗅探在这里只当否决权用。 +func checkInlinePayload(declared string, data []byte) (string, []byte, error) { + if len(data) == 0 { + return "", nil, fmt.Errorf("vision: %w: %s payload is empty — the data: URI carries no base64 bytes; hint: %s", + ErrNotImage, declaredLabel(declared), reuseHint) + } + if _, err := imageMIMEFor(declared, data); err != nil { + return "", nil, fmt.Errorf("vision: %w: %s payload is %s; supported formats: %s; leading bytes: %s; hint: %s", + ErrNotImage, declaredLabel(declared), mediaType(sniffMIME(data)), supportedFormats, hexHead(data), reuseHint) + } + return declared, data, nil +} + +// hexHead 把载荷开头若干字节渲染成十六进制,作为「它看起来是什么」的物证。 +// 只给固定长度的头部 —— **绝不回显正文**:载荷可能很大,也可能含隐私内容 +// (同 imageMIMEFor 的口径:错误信息只含类型名,不含图像字节)。 +func hexHead(data []byte) string { + n := min(len(data), evidenceBytes) + parts := make([]string, 0, n) + for _, b := range data[:n] { + parts = append(parts, fmt.Sprintf("%02x", b)) + } + return strings.Join(parts, " ") +} diff --git a/internal/runtime/multimodal/vision/payload_test.go b/internal/runtime/multimodal/vision/payload_test.go new file mode 100644 index 0000000..6ad7c10 --- /dev/null +++ b/internal/runtime/multimodal/vision/payload_test.go @@ -0,0 +1,250 @@ +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, ";base64," source. +// +// 空载荷是**明确拒绝**而不是交给解码器:`data:image/png;base64,` 这种「只有前缀、 +// 没有载荷」的入参实测出现过(模型复述不出几万字符的 base64,只把前缀抄了下来), +// 旧路径把它解成零字节再报 `image: unknown format`,指不到真正的毛病。前缀本身 +// 不含图片正文,故可以安全回显(按 cropEvidenceBytes 截断,防止畸形长前缀灌进日志)。 func decodeCropDataURI(src string) ([]byte, error) { comma := strings.IndexByte(src, ',') if comma < 0 || !strings.Contains(src[:comma], ";base64") { return nil, fmt.Errorf("io: image-crop: unsupported data URI (expected base64)") } - data, err := base64.StdEncoding.DecodeString(src[comma+1:]) + payload := src[comma+1:] + if payload == "" { + return nil, fmt.Errorf("io: image-crop: data URI %q carries no payload (the base64 part is empty); "+ + "pass the actual image bytes, or pass its http(s) URL / local path instead of retyping a data: URI", + truncateHead(src[:comma+1], cropEvidenceBytes)) + } + data, err := base64.StdEncoding.DecodeString(payload) if err != nil { return nil, fmt.Errorf("io: image-crop: decode data URI: %w", err) } @@ -247,6 +263,15 @@ func decodeCropDataURI(src string) ([]byte, error) { return data, nil } +// truncateHead 把串截到至多 n 个字符,用于把**有限的、受控的**上下文写进错误 +// 文案(如 data: 前缀);图片正文与 base64 载荷一律不进错误信息。 +func truncateHead(s string, n int) string { + if len(s) > n { + return s[:n] + "…" + } + return s +} + // readFile reads a path through the file tool's sandbox so cropping can never // reach a file the file tool could not read (裁决 13.4.4:文件沙箱唯一实现处). func (c *cropTool) readFile(p string) ([]byte, error) { diff --git a/plugins/tools/io/crop_test.go b/plugins/tools/io/crop_test.go index 13aff0f..7fbfac6 100644 --- a/plugins/tools/io/crop_test.go +++ b/plugins/tools/io/crop_test.go @@ -6,6 +6,7 @@ import ( "encoding/base64" "image" "image/color" + "image/jpeg" "image/png" "net/http" "net/http/httptest" @@ -179,6 +180,148 @@ func TestCropTool_RejectsUnsupportedDataURI(t *testing.T) { } } +// 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("502 Bad Gateway") + 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, " cropSniffBytes { + data = data[:cropSniffBytes] + } + mt := http.DetectContentType(data) + return strings.ToLower(strings.TrimSpace(strings.Split(mt, ";")[0])) +} + +// cropDecodableMIME 报告 mime 是否是本工具能本地解码的格式。 +func cropDecodableMIME(mime string) bool { + switch mime { + case "image/png", "image/jpeg", "image/gif": + return true + default: + return false + } +} + +// hexHead 把载荷开头 cropEvidenceBytes 个字节渲染成十六进制,作为「它到底像 +// 什么」的物证。只给固定的头部长度:错误信息里不得出现图片正文或 base64 载荷。 +func hexHead(data []byte) string { + if len(data) > cropEvidenceBytes { + data = data[:cropEvidenceBytes] + } + parts := make([]string, 0, len(data)) + for _, b := range data { + parts = append(parts, fmt.Sprintf("%02x", b)) + } + return strings.Join(parts, " ") +}