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("PROXY error page") 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(``), "", 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()) } }