- mediafetch 新增**图片白名单**(扩展名 + **字节嗅探兜底**;与文档/音频**三份名单互不污染**),
取回/裁决/上限全部走既有 Fetch(**来源白名单 deny-by-default** + 逐跳重定向校验 + 边读边限 + 超时)
⇒ 没有新造第四份取回实现
- 微信:给图片 URL 配来源白名单(沿用该渠道 CDN 根口径)
- vision 的 `data:` URI 内联入口为**只读复用**(未改 vision 一行)
- **行为变更**:图片现在**会**被尝试取回(**字节嗅探是唯一裁决权** —— 不是图片字节就不写 `Data`);
其它渠道尚无图片来源白名单 ⇒ 由 deny-by-default 拒绝(**安全**,只是暂读不到,走诚实降级)——
故同步更新了 kook 的既有测试(原断言「图片保持既有路径 = 不取回」已随口径演进)
- ⚠️ **`channelbridge` 一个字没碰**(接线点在切片报告 §7),等监工接线 + 补其它渠道白名单
- 测试:3 个新文件;变异 5 条
248 lines
8.2 KiB
Go
248 lines
8.2 KiB
Go
package mediafetch
|
|
|
|
// 本文件锁定共享内联循环的口径(切片 IM-1):预筛、限额、条数上限、失败不炸。
|
|
// 「正文里的任意 URL ⇒ 0 次命中」这条红线在各适配器的用例里断言(只有适配器才知道
|
|
// 正文长什么样);这里锁的是「循环只会把**媒体引用**交给 Fetcher」这条结构性事实。
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"strings"
|
|
"sync"
|
|
"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"
|
|
)
|
|
|
|
// countingFetcher 记录每次被要求取回的媒体引用(引用原串与次数),使「0 次命中」
|
|
// 与「命中次数」都可断言。
|
|
type countingFetcher struct {
|
|
mu sync.Mutex
|
|
seen []string
|
|
data []byte
|
|
err error
|
|
maxHit int
|
|
}
|
|
|
|
func (f *countingFetcher) Fetch(_ context.Context, m adapter.Media) ([]byte, error) {
|
|
f.mu.Lock()
|
|
f.seen = append(f.seen, m.URL)
|
|
n := len(f.seen)
|
|
f.mu.Unlock()
|
|
if f.maxHit > 0 && n > f.maxHit {
|
|
return nil, errors.New("超过了本用例允许的取回次数")
|
|
}
|
|
if f.err != nil {
|
|
return nil, f.err
|
|
}
|
|
return f.data, nil
|
|
}
|
|
|
|
func (f *countingFetcher) calls() int {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return len(f.seen)
|
|
}
|
|
|
|
// testLogger 返回一个丢弃输出的带来源日志器:用例不关心内容,只保证路径不 panic。
|
|
func testLogger(t *testing.T) *slog.Logger {
|
|
t.Helper()
|
|
return logger.WithSource(logger.New(logger.Options{Out: io.Discard}), "internal/channel/mediafetch")
|
|
}
|
|
|
|
// txtMessage 构造一条「带一个 .txt 附件」的入站消息。
|
|
func txtMessage(url string) *adapter.UnifiedMessage {
|
|
return &adapter.UnifiedMessage{
|
|
Platform: "test",
|
|
ContentType: adapter.ContentFile,
|
|
Content: "看看这个文件 " + url, // 正文里也带同一个 URL:正文**不参与**取回决策
|
|
Media: []adapter.Media{{
|
|
Kind: adapter.MediaFile, URL: url, Name: "notes.txt", MIMEType: "text/plain", Size: 12,
|
|
}},
|
|
}
|
|
}
|
|
|
|
// TestInlineDownloadsAllowedFile 是正向用例:白名单格式的文件附件被内联进 Data。
|
|
func TestInlineDownloadsAllowedFile(t *testing.T) {
|
|
t.Parallel()
|
|
f := &countingFetcher{data: []byte("hello\n")}
|
|
msg := txtMessage("https://cdn.example.com/notes.txt")
|
|
|
|
Inline(context.Background(), testLogger(t), msg, f)
|
|
|
|
if f.calls() != 1 {
|
|
t.Fatalf("取回次数 = %d, want 1", f.calls())
|
|
}
|
|
if string(msg.Media[0].Data) != "hello\n" {
|
|
t.Fatalf("Data = %q, want 取回的字节", msg.Media[0].Data)
|
|
}
|
|
}
|
|
|
|
// TestInlineNeverFetchesFromBody 钉住结构性事实:循环只把 `Media[].URL` 交给
|
|
// Fetcher —— 正文里那个 URL 永远不是取回对象(它是同一条文本,但只作为字符串存在)。
|
|
func TestInlineNeverFetchesFromBody(t *testing.T) {
|
|
t.Parallel()
|
|
f := &countingFetcher{data: []byte("ok")}
|
|
msg := txtMessage("https://cdn.example.com/notes.txt")
|
|
bodyURL := "https://evil.example.com/secret.txt"
|
|
msg.Content = "读一下 " + bodyURL + " 和 " + msg.Media[0].URL
|
|
msg.Media = nil // 只有正文带 URL、没有任何文件媒体
|
|
|
|
Inline(context.Background(), testLogger(t), msg, f)
|
|
|
|
if f.calls() != 0 {
|
|
t.Fatalf("正文里的 URL 被取回了(%v),want 0 次", f.seen)
|
|
}
|
|
}
|
|
|
|
// TestInlineSkipsUnsupportedExtensionWithoutFetch 钉住预筛:非白名单格式**不下载**。
|
|
func TestInlineSkipsUnsupportedExtensionWithoutFetch(t *testing.T) {
|
|
t.Parallel()
|
|
for _, name := range []string{"payload.exe", "script.js", "page.html", "archive.zip", ""} {
|
|
t.Run(name, func(t *testing.T) {
|
|
t.Parallel()
|
|
f := &countingFetcher{data: []byte("x")}
|
|
msg := txtMessage("https://cdn.example.com/whatever")
|
|
msg.Media[0].Name = name
|
|
|
|
Inline(context.Background(), testLogger(t), msg, f)
|
|
|
|
if f.calls() != 0 {
|
|
t.Fatalf("格式 %q 不在白名单里却发起了取回", name)
|
|
}
|
|
if len(msg.Media[0].Data) != 0 {
|
|
t.Fatalf("格式 %q 不该被内联", name)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestInlineSkipsDeclaredOversizeWithoutFetch 钉住「平台声明的体积超上限时连请求都
|
|
// 不发」(省一次必然失败的大传输)。
|
|
func TestInlineSkipsDeclaredOversizeWithoutFetch(t *testing.T) {
|
|
t.Parallel()
|
|
f := &countingFetcher{data: []byte("x")}
|
|
msg := txtMessage("https://cdn.example.com/big.txt")
|
|
msg.Media[0].Size = documents.DefaultMaxBytes + 1
|
|
|
|
Inline(context.Background(), testLogger(t), msg, f)
|
|
|
|
if f.calls() != 0 {
|
|
t.Fatalf("声明超限的附件仍被取回 %d 次,want 0", f.calls())
|
|
}
|
|
}
|
|
|
|
// TestInlineFailureKeepsMessageAndContinues 钉住「失败不炸」:一份取不到不阻断其余
|
|
// 附件,也不改动消息的其他字段。
|
|
func TestInlineFailureKeepsMessageAndContinues(t *testing.T) {
|
|
t.Parallel()
|
|
f := &countingFetcher{err: ErrBadStatus}
|
|
msg := txtMessage("https://cdn.example.com/a.txt")
|
|
msg.Media = append(msg.Media, adapter.Media{
|
|
Kind: adapter.MediaFile, URL: "https://cdn.example.com/b.txt", Name: "b.txt",
|
|
})
|
|
|
|
Inline(context.Background(), testLogger(t), msg, f)
|
|
|
|
if f.calls() != 2 {
|
|
t.Fatalf("取回次数 = %d, want 2(一次失败不阻断第二次)", f.calls())
|
|
}
|
|
for i, m := range msg.Media {
|
|
if len(m.Data) != 0 {
|
|
t.Fatalf("Media[%d] 在失败时被写入了字节", i)
|
|
}
|
|
}
|
|
if !strings.Contains(msg.Content, "https://cdn.example.com/a.txt") {
|
|
t.Fatalf("失败路径改动了正文:%q", msg.Content)
|
|
}
|
|
}
|
|
|
|
// TestInlineCapsAttachmentsPerMessage 钉住条数上限与 Web 侧同源(documents.MaxPerMessage)。
|
|
func TestInlineCapsAttachmentsPerMessage(t *testing.T) {
|
|
t.Parallel()
|
|
f := &countingFetcher{data: []byte("x")}
|
|
msg := txtMessage("https://cdn.example.com/0.txt")
|
|
for i := 1; i <= documents.MaxPerMessage+2; i++ {
|
|
msg.Media = append(msg.Media, adapter.Media{
|
|
Kind: adapter.MediaFile, URL: "https://cdn.example.com/x", Name: "x.txt",
|
|
})
|
|
}
|
|
|
|
Inline(context.Background(), testLogger(t), msg, f)
|
|
|
|
if f.calls() != documents.MaxPerMessage {
|
|
t.Fatalf("取回次数 = %d, want %d(与 Web 侧同一上限)", f.calls(), documents.MaxPerMessage)
|
|
}
|
|
}
|
|
|
|
// TestInlineSkipsMediaThatAlreadyHasBytes 钉住「已有字节不重复下载」(本地产出的
|
|
// 附件照原样通过)。
|
|
func TestInlineSkipsMediaThatAlreadyHasBytes(t *testing.T) {
|
|
t.Parallel()
|
|
f := &countingFetcher{data: []byte("new")}
|
|
msg := txtMessage("https://cdn.example.com/a.txt")
|
|
msg.Media[0].Data = []byte("local")
|
|
|
|
Inline(context.Background(), testLogger(t), msg, f)
|
|
|
|
if f.calls() != 0 {
|
|
t.Fatalf("已有字节的附件被重复下载 %d 次", f.calls())
|
|
}
|
|
if string(msg.Media[0].Data) != "local" {
|
|
t.Fatalf("本地字节被覆盖:%q", msg.Media[0].Data)
|
|
}
|
|
}
|
|
|
|
// TestInlineIgnoresNonImageAndNonAudioVoice 钉住分支边界:**声明不是图片格式**的图片媒体
|
|
// (这里是 `.txt`)与格式不可转写的语音一样**不取字节**(切片 IM-IMG 起,声明命中图片白名单
|
|
// 的图片**会**被 `inlineImages` 取回,见 inline_image_test.go;音频白名单里的语音同理见
|
|
// inline_voice_test.go)。
|
|
func TestInlineIgnoresNonImageAndNonAudioVoice(t *testing.T) {
|
|
t.Parallel()
|
|
f := &countingFetcher{data: []byte("x")}
|
|
msg := &adapter.UnifiedMessage{
|
|
Platform: "test",
|
|
ContentType: adapter.ContentImage,
|
|
Media: []adapter.Media{
|
|
{Kind: adapter.MediaImage, URL: "https://cdn.example.com/a.txt", Name: "a.txt"},
|
|
{Kind: adapter.MediaVoice, URL: "https://cdn.example.com/b.txt", Name: "b.txt"},
|
|
},
|
|
}
|
|
|
|
Inline(context.Background(), testLogger(t), msg, f)
|
|
|
|
if f.calls() != 0 {
|
|
t.Fatalf("声明不是图片格式的媒体被取回了 %d 次,want 0", f.calls())
|
|
}
|
|
}
|
|
|
|
// TestDisplayNameFallsBackToURLPath 钉住缺名时的兜底:从资产地址路径末段取扩展名。
|
|
func TestDisplayNameFallsBackToURLPath(t *testing.T) {
|
|
t.Parallel()
|
|
cases := []struct {
|
|
name string
|
|
url string
|
|
want string
|
|
}{
|
|
{"文件名优先", "https://cdn.example.com/download?id=1", "report.csv"},
|
|
{"无名字取路径末段", "https://cdn.example.com/2024/report.csv", "report.csv"},
|
|
{"路径无末段则空", "https://cdn.example.com/", ""},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
m := adapter.Media{Name: "", URL: tc.url}
|
|
if tc.name == "文件名优先" {
|
|
m.Name = "report.csv"
|
|
}
|
|
if got := displayName(m); got != tc.want {
|
|
t.Fatalf("displayName = %q, want %q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|