Files
Cyrene_v2/internal/channel/mediafetch/inline_image_test.go
T
AskaEth 6a545badbd
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(channels): IM 入站图片采集层(图片白名单 + 微信图片来源 + 复用 vision 内联入口)
- mediafetch 新增**图片白名单**(扩展名 + **字节嗅探兜底**;与文档/音频**三份名单互不污染**),
  取回/裁决/上限全部走既有 Fetch(**来源白名单 deny-by-default** + 逐跳重定向校验 + 边读边限 + 超时)
  ⇒ 没有新造第四份取回实现
- 微信:给图片 URL 配来源白名单(沿用该渠道 CDN 根口径)
- vision 的 `data:` URI 内联入口为**只读复用**(未改 vision 一行)
- **行为变更**:图片现在**会**被尝试取回(**字节嗅探是唯一裁决权** —— 不是图片字节就不写 `Data`);
  其它渠道尚无图片来源白名单 ⇒ 由 deny-by-default 拒绝(**安全**,只是暂读不到,走诚实降级)——
  故同步更新了 kook 的既有测试(原断言「图片保持既有路径 = 不取回」已随口径演进)
- ⚠️ **`channelbridge` 一个字没碰**(接线点在切片报告 §7),等监工接线 + 补其它渠道白名单
- 测试:3 个新文件;变异 5 条
2026-10-05 11:04:41 +08:00

486 lines
19 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 mediafetch
// 本文件锁定**入站图片字节内联**的口径(切片 IM-IMG):
// - 正向:白名单声明的图片、以及「平台类型即声明」的无名无 MIME 图片(微信 iLink 的形状)
// 都会被取回并写进 `Media.Data`(①);
// - 反证:非白名单声明/非图片字节 ⇒ **0 次取回**;顶着图片声明的非图片字节取回后
// **绝不内联**(②);
// - 超限:声明超限不取回、取回的字节超限不内联,两条都留一条诚实日志(③);
// - 失败/超时:不炸、不静默(日志带来源,**绝不记 URL 原串**)(④);
// - **图片白名单绝不污染文档白名单与音频白名单**(⑤)。
import (
"bytes"
"context"
"log/slog"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
)
// 各格式的**最小字节样本**:`http.DetectContentType` 只认前缀签名,因此这些就是
// 「真图片」在该函数眼中的全部依据(TIFF 由本包自己的 tiffHeader 补签名)。
var (
pngSample = []byte("\x89PNG\r\n\x1a\n" + "fake-png-body")
jpegSample = []byte("\xff\xd8\xff\xe0" + "fake-jpeg-body")
gifSample = []byte("GIF89a" + "fake-gif-body")
webpSample = []byte("RIFF\x00\x00\x00\x00WEBPVP8 " + "fake-webp-body")
bmpSample = []byte("BM\x00\x00\x00\x00" + "fake-bmp-body")
tiffSample = []byte("II\x2a\x00\x08\x00\x00\x00" + "fake-tiff-body")
tiffBE = []byte("MM\x00\x2a\x00\x00\x00\x08" + "fake-tiff-body")
htmlSample = []byte("<!DOCTYPE html><html><body>PROXY error page</body></html>")
textSample = []byte("这不是图片,只是一段文本。")
)
// imageMessage 构造一条「只有一张图片」的入站消息(Kind 明确,与 QQ/kook 的形状一致)。
func imageMessage(m adapter.Media) *adapter.UnifiedMessage {
return &adapter.UnifiedMessage{
Platform: "test",
ContentType: adapter.ContentImage,
Media: []adapter.Media{m},
}
}
// logBuffer 返回一个把日志写进内存的带来源日志器:用例据此断言「失败不静默」。
func logBuffer(t *testing.T) (*slog.Logger, *bytes.Buffer) {
t.Helper()
var buf bytes.Buffer
return logger.WithSource(logger.New(logger.Options{Level: "debug", Out: &buf}),
"internal/channel/mediafetch"), &buf
}
// TestInlineImageFetchesWhitelistedImage 是正向用例:扩展名与字节都是白名单图片 ⇒ 取回一次
// 并写进 Data(并且把**嗅探所得**的 MIME 记进日志,不是平台声明的那一个)。
func TestInlineImageFetchesWhitelistedImage(t *testing.T) {
t.Parallel()
f := &countingFetcher{data: pngSample}
msg := imageMessage(adapter.Media{
Kind: adapter.MediaImage, URL: "https://cdn.example.com/a.png", Name: "a.png", MIMEType: "image/png",
})
Inline(context.Background(), testLogger(t), msg, f)
if f.calls() != 1 {
t.Fatalf("取回次数 = %d, want 1", f.calls())
}
if !bytes.Equal(msg.Media[0].Data, pngSample) {
t.Fatalf("Data = %q, want 取回的图片字节", msg.Media[0].Data)
}
}
// TestInlineImageFetchesPlatformTypedImageWithoutNameOrMIME 是**微信 iLink 的形状**:
// image_item 既没有 file_name 也没有 mime,只有平台类型与 CDN 引用 —— 这正是
// 「只认扩展名就会把微信图片整条挡掉」的那个缺口。
func TestInlineImageFetchesPlatformTypedImageWithoutNameOrMIME(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
data []byte
mime string
}{
{"gif", gifSample, "image/gif"},
{"jpeg", jpegSample, "image/jpeg"},
{"webp", webpSample, "image/webp"},
{"bmp", bmpSample, "image/bmp"},
{"tiff 小端", tiffSample, "image/tiff"},
{"tiff 大端", tiffBE, "image/tiff"},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
f := &countingFetcher{data: tc.data}
msg := imageMessage(adapter.Media{
Kind: adapter.MediaImage,
// 无 Name、无 MIMEType:引用形如 CDN 的相对下载引用(归一后是绝对地址)。
URL: "https://cdn.example.com/c2c/download?encrypted_query_param=SECRET-VALUE",
})
Inline(context.Background(), testLogger(t), msg, f)
if f.calls() != 1 {
t.Fatalf("取回次数 = %d, want 1(平台类型即声明)", f.calls())
}
if !bytes.Equal(msg.Media[0].Data, tc.data) {
t.Fatalf("Data = %q, want 取回的字节", msg.Media[0].Data)
}
if got, ok := ImageMIMEOf(msg.Media[0].Data); !ok || got != tc.mime {
t.Fatalf("ImageMIMEOf = %q/%v, want %s/true", got, ok, tc.mime)
}
})
}
}
// TestInlineImageWithCaptionIsStillInlined 钉住与语音**刻意不同**的一条:消息带正文时图片
// 仍要取(配文的图同样是一份内容),绝不能照语音那条「已有文字就跳过」的口径走。
func TestInlineImageWithCaptionIsStillInlined(t *testing.T) {
t.Parallel()
f := &countingFetcher{data: pngSample}
msg := imageMessage(adapter.Media{
Kind: adapter.MediaImage, URL: "https://cdn.example.com/a.png", Name: "a.png",
})
msg.Content = "看看这张图 [图片]"
Inline(context.Background(), testLogger(t), msg, f)
if f.calls() != 1 || len(msg.Media[0].Data) == 0 {
t.Fatalf("带正文的图片没有被内联:calls=%d data=%d 字节", f.calls(), len(msg.Media[0].Data))
}
}
// TestInlineImageSkipsNonImageDeclarationWithoutFetch 是**反证**(②):声明不指向白名单
// 图片时**一个字节都不发出去**(取回次数 0,Data 为空)。
func TestInlineImageSkipsNonImageDeclarationWithoutFetch(t *testing.T) {
t.Parallel()
cases := []struct {
name string
url string
m adapter.Media
}{
{"扩展名是 html", "https://cdn.example.com/x", adapter.Media{Kind: adapter.MediaImage, Name: "page.html"}},
{"扩展名是 exe", "https://cdn.example.com/x", adapter.Media{Kind: adapter.MediaImage, Name: "payload.exe"}},
{"扩展名是 zip", "https://cdn.example.com/x", adapter.Media{Kind: adapter.MediaImage, Name: "bundle.zip"}},
{"地址末段是 zip", "https://cdn.example.com/bundle.zip", adapter.Media{Kind: adapter.MediaImage}},
{"声明 MIME 是 html", "https://cdn.example.com/x", adapter.Media{Kind: adapter.MediaImage, MIMEType: "text/html"}},
{"声明 MIME 是 svg", "https://cdn.example.com/x", adapter.Media{Kind: adapter.MediaImage, MIMEType: "image/svg+xml"}},
{"声明 MIME 是 pdf", "https://cdn.example.com/x", adapter.Media{Kind: adapter.MediaImage, MIMEType: "application/pdf"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
f := &countingFetcher{data: pngSample}
m := tc.m
m.URL = tc.url
msg := imageMessage(m)
Inline(context.Background(), testLogger(t), msg, f)
if f.calls() != 0 {
t.Fatalf("非图片声明发起了 %d 次取回,want 0(反证)", f.calls())
}
if len(msg.Media[0].Data) != 0 {
t.Fatal("非图片声明被内联了")
}
})
}
}
// TestInlineImageSniffVetoesMislabeledBytes 钉住「字节嗅探是唯一裁决权」(②的后半):
// 声明是 `.png`/`image/png`,字节却是 HTML ⇒ 取回发生过(必须看字节才知道),但**绝不内联**,
// 并且留一条诚实日志。去掉嗅探(只信声明)这条用例就会红。
func TestInlineImageSniffVetoesMislabeledBytes(t *testing.T) {
t.Parallel()
cases := []struct {
name string
data []byte
want string
}{
{"HTML 错误页", htmlSample, "not a whitelisted raster image"},
{"纯文本", textSample, "not a whitelisted raster image"},
{"空响应体", nil, "returned no bytes"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
f := &countingFetcher{data: tc.data}
msg := imageMessage(adapter.Media{
Kind: adapter.MediaImage, URL: "https://cdn.example.com/a.png", Name: "a.png", MIMEType: "image/png",
})
log, buf := logBuffer(t)
Inline(context.Background(), log, msg, f)
if len(msg.Media[0].Data) != 0 {
t.Fatalf("非图片字节(len=%d)被内联进了上下文", len(msg.Media[0].Data))
}
if !strings.Contains(buf.String(), tc.want) {
t.Fatalf("字节被否决时没有留下诚实日志(want %q):%q", tc.want, buf.String())
}
})
}
}
// TestInlineImageDeclaredOversizeNotFetched 钉住声明超限不取回(③前半)。
func TestInlineImageDeclaredOversizeNotFetched(t *testing.T) {
t.Parallel()
f := &countingFetcher{data: pngSample}
msg := imageMessage(adapter.Media{
Kind: adapter.MediaImage, URL: "https://cdn.example.com/big.png", Name: "big.png",
Size: MaxImageBytes + 1,
})
log, buf := logBuffer(t)
Inline(context.Background(), log, msg, f)
if f.calls() != 0 {
t.Fatalf("声明超限的图片仍被取回 %d 次,want 0", f.calls())
}
if !strings.Contains(buf.String(), "declared size over limit") {
t.Fatalf("声明超限没有留下日志:%q", buf.String())
}
}
// TestInlineImageFetchedOversizeNotInlined 钉住「取回的字节超限一律不内联」(③后半,纵深):
// 渠道 Fetcher 没按图片口径设上限时(各渠道尚未全部改用 MaxBytesFor),取回层这一道
// 仍然拦得住,绝不会把一份巨图塞进上下文。
func TestInlineImageFetchedOversizeNotInlined(t *testing.T) {
t.Parallel()
f := &countingFetcher{data: append(append([]byte(nil), pngSample...), make([]byte, MaxImageBytes)...)}
msg := imageMessage(adapter.Media{
Kind: adapter.MediaImage, URL: "https://cdn.example.com/huge.png", Name: "huge.png",
})
log, buf := logBuffer(t)
Inline(context.Background(), log, msg, f)
if len(msg.Media[0].Data) != 0 {
t.Fatal("超限的图片字节被内联了")
}
if !strings.Contains(buf.String(), "fetched size over limit") {
t.Fatalf("超限没有留下日志:%q", buf.String())
}
}
// TestInlineImageSkipsExistingBytesAndCapsPerMessage 钉住两条省流与限额:已有字节不重复
// 下载;同一条消息里最多内联 `documents.MaxPerMessage` 张。
func TestInlineImageSkipsExistingBytesAndCapsPerMessage(t *testing.T) {
t.Parallel()
t.Run("已有字节", func(t *testing.T) {
t.Parallel()
f := &countingFetcher{data: pngSample}
msg := imageMessage(adapter.Media{
Kind: adapter.MediaImage, URL: "https://cdn.example.com/a.png", Name: "a.png", Data: []byte("local"),
})
Inline(context.Background(), testLogger(t), msg, f)
if f.calls() != 0 || string(msg.Media[0].Data) != "local" {
t.Fatalf("已有字节的图片被重复下载或覆盖:calls=%d data=%q", f.calls(), msg.Media[0].Data)
}
})
t.Run("条数上限", func(t *testing.T) {
t.Parallel()
f := &countingFetcher{data: pngSample}
msg := imageMessage(adapter.Media{
Kind: adapter.MediaImage, URL: "https://cdn.example.com/0.png", Name: "0.png",
})
for i := 1; i <= documents.MaxPerMessage+2; i++ {
msg.Media = append(msg.Media, adapter.Media{
Kind: adapter.MediaImage, URL: "https://cdn.example.com/x.png", Name: "x.png",
})
}
Inline(context.Background(), testLogger(t), msg, f)
if f.calls() != documents.MaxPerMessage {
t.Fatalf("取回次数 = %d, want %d", f.calls(), documents.MaxPerMessage)
}
})
}
// TestInlineImageQuotaIsSeparateFromFilesAndVoices 钉住「三条链路各自计额度」:同一条消息里
// 的文件、图片、语音互不吃掉对方的条数额度。
func TestInlineImageQuotaIsSeparateFromFilesAndVoices(t *testing.T) {
t.Parallel()
f := &countingFetcher{data: pngSample}
msg := &adapter.UnifiedMessage{Platform: "test"}
for i := 0; i < documents.MaxPerMessage; i++ {
msg.Media = append(msg.Media,
adapter.Media{Kind: adapter.MediaFile, URL: "https://cdn.example.com/f.txt", Name: "f.txt"})
}
msg.Media = append(msg.Media,
adapter.Media{Kind: adapter.MediaImage, URL: "https://cdn.example.com/p.png", Name: "p.png"})
msg.Media = append(msg.Media,
adapter.Media{Kind: adapter.MediaVoice, URL: "https://cdn.example.com/v.ogg", Name: "v.ogg"})
Inline(context.Background(), testLogger(t), msg, f)
// 5 个文件 + 1 张图 + 1 条语音:三条链路各自在额度内,全部取回。
if f.calls() != documents.MaxPerMessage+2 {
t.Fatalf("取回次数 = %d, want %d(三条链路各自的额度不许互相吃掉)",
f.calls(), documents.MaxPerMessage+2)
}
}
// TestInlineImageFailureIsHonestNotSilent 钉住失败/超时路径(④):不炸、消息其余字段不动、
// 留一条**带来源**的 Warn;并且日志里**只有来源主机**,绝不出现完整 URL(可能带签名凭据)。
func TestInlineImageFailureIsHonestNotSilent(t *testing.T) {
t.Parallel()
const secret = "encrypted_query_param=SUPER_SECRET_VALUE"
cases := []struct {
name string
err error
}{
{"平台返回非成功状态", ErrBadStatus},
{"超时", context.DeadlineExceeded},
{"来源不在白名单", ErrOriginNotAllowed},
{"超过体积上限", ErrTooLarge},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
f := &countingFetcher{err: tc.err}
msg := imageMessage(adapter.Media{
Kind: adapter.MediaImage, URL: "https://cdn.example.com/c2c/download?" + secret, Name: "a.png",
})
msg.Content = "[图片]"
log, buf := logBuffer(t)
Inline(context.Background(), log, msg, f)
if len(msg.Media[0].Data) != 0 {
t.Fatal("失败时仍写入了字节")
}
if msg.Content != "[图片]" {
t.Fatalf("失败路径改动了正文:%q(诚实提示由桥接器负责)", msg.Content)
}
out := buf.String()
if !strings.Contains(out, "inbound image inline failed") {
t.Fatalf("失败路径静默了:%q", out)
}
if !strings.Contains(out, "source=internal/channel/mediafetch") {
t.Fatalf("日志没有带来源(AGENTS §10):%q", out)
}
if !strings.Contains(out, "host=https://cdn.example.com") {
t.Fatalf("日志里没有来源主机,定位不到是哪台平台主机:%q", out)
}
if strings.Contains(out, secret) || strings.Contains(out, "/c2c/download") {
t.Fatalf("日志泄露了完整 URL 或签名参数:%q", out)
}
})
}
}
// TestInlineImageEmptyBytesIsHonest 钉住「取到 0 字节」这条分支同样不静默。
func TestInlineImageEmptyBytesIsHonest(t *testing.T) {
t.Parallel()
f := &countingFetcher{data: nil}
msg := imageMessage(adapter.Media{
Kind: adapter.MediaImage, URL: "https://cdn.example.com/a.png", Name: "a.png",
})
log, buf := logBuffer(t)
Inline(context.Background(), log, msg, f)
if !strings.Contains(buf.String(), "returned no bytes") {
t.Fatalf("0 字节路径静默了:%q", buf.String())
}
}
// TestImageWhitelistDoesNotPolluteOtherWhitelists 是本切片的硬红线(⑤):图片放行集合是
// **独立**的一份 —— 既不许污染 `documents.ExtAllowed`(Web 文档上传口径),也不许与音频
// 白名单互相渗透。
func TestImageWhitelistDoesNotPolluteOtherWhitelists(t *testing.T) {
t.Parallel()
imageExts := []string{".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff"}
for _, ext := range imageExts {
if !imageExtAllowed(ext) {
t.Fatalf("imageExtAllowed(%q) = false, want true", ext)
}
if documents.ExtAllowed(ext) {
t.Fatalf("documents.ExtAllowed(%q) = true —— 图片格式污染了文档白名单", ext)
}
if audioExtAllowed(ext) {
t.Fatalf("audioExtAllowed(%q) = true —— 图片格式污染了音频白名单", ext)
}
}
// 反方向:文档/音频/可执行格式都不是图片。
for _, ext := range []string{".txt", ".pdf", ".mp3", ".ogg", ".wav", ".exe", ".svg", ".html", ""} {
if imageExtAllowed(ext) {
t.Fatalf("imageExtAllowed(%q) = true, want false", ext)
}
}
// 三份集合的规模与成员都不互相包含(防止「一条路复用两份名单」)。
if documents.ExtAllowed(".png") || documents.ExtAllowed(".mp3") || documents.ExtAllowed(".amr") {
t.Fatal("文档白名单里出现了图片或音频格式")
}
if audioExtAllowed(".png") || audioExtAllowed(".pdf") {
t.Fatal("音频白名单里出现了图片或文档格式")
}
}
// TestImageMIMEOfIsTheByteArbiter 钉住字节裁决本身:白名单光栅图认得出来、SVG/HTML/文本/
// 空字节一律判否,且 `image/jpg` 这种非标准写法被归一到 `image/jpeg`。
func TestImageMIMEOfIsTheByteArbiter(t *testing.T) {
t.Parallel()
cases := []struct {
name string
data []byte
want string
ok bool
}{
{"png", pngSample, "image/png", true},
{"jpeg", jpegSample, "image/jpeg", true},
{"gif", gifSample, "image/gif", true},
{"webp", webpSample, "image/webp", true},
{"bmp", bmpSample, "image/bmp", true},
{"tiff 小端", tiffSample, "image/tiff", true},
{"tiff 大端", tiffBE, "image/tiff", true},
{"html 错误页", htmlSample, "", false},
{"纯文本", textSample, "", false},
{"svg", []byte(`<svg xmlns="http://www.w3.org/2000/svg"></svg>`), "", false},
{"空字节", nil, "", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got, ok := ImageMIMEOf(tc.data)
if ok != tc.ok || got != tc.want {
t.Fatalf("ImageMIMEOf = %q/%v, want %q/%v", got, ok, tc.want, tc.ok)
}
})
}
if got, ok := knownImageMIME("image/jpg"); !ok || got != "image/jpeg" {
t.Fatalf("knownImageMIME(image/jpg) = %q/%v, want image/jpeg/true", got, ok)
}
if _, ok := knownImageMIME("image/svg+xml"); ok {
t.Fatal("SVG 被图片白名单放行了(它是可执行内容)")
}
}
// TestMaxBytesForPerKind 钉住「上限按类型分档」的唯一出处:图片与语音 5 MiB、其余文档类
// 20 MiB —— 各渠道 Fetcher 只该引用它,不许自己写数。
func TestMaxBytesForPerKind(t *testing.T) {
t.Parallel()
cases := []struct {
name string
m adapter.Media
want int64
}{
{"图片", adapter.Media{Kind: adapter.MediaImage}, MaxImageBytes},
{"语音", adapter.Media{Kind: adapter.MediaVoice}, MaxVoiceBytes},
{"文件", adapter.Media{Kind: adapter.MediaFile}, documents.DefaultMaxBytes},
{"无类型", adapter.Media{}, documents.DefaultMaxBytes},
}
for _, tc := range cases {
if got := MaxBytesFor(tc.m); got != tc.want {
t.Fatalf("%s: MaxBytesFor = %d, want %d", tc.name, got, tc.want)
}
}
if MaxImageBytes == documents.DefaultMaxBytes {
t.Fatal("图片上限与文档上限撞在同一个数上:本切片要求图片用 5 MiB、文档用 20 MiB")
}
}
// TestInlineImageIgnoresOtherMediaKinds 钉住分支边界:图片分支只碰图片媒体,文件/语音媒体
// 不会被它写入 Data(各自的分支负责),且非图片媒体的失败不会影响图片。
func TestInlineImageIgnoresOtherMediaKinds(t *testing.T) {
t.Parallel()
f := &countingFetcher{data: pngSample}
msg := &adapter.UnifiedMessage{
Platform: "test",
Media: []adapter.Media{
{Kind: adapter.MediaFile, URL: "https://cdn.example.com/f.txt", Name: "f.txt"},
{Kind: adapter.MediaVoice, URL: "https://cdn.example.com/v.silk", Name: "v.silk"},
},
}
log, _ := logBuffer(t)
inlineImages(context.Background(), log, msg, f)
if f.calls() != 0 {
t.Fatalf("图片分支对非图片媒体发起了 %d 次取回,want 0", f.calls())
}
}