Files
Cyrene_v2/internal/runtime/multimodal/vision/image.go
T
AskaEth 4c187ac070 refactor(trace): 把剥 url.Error 壳的实现收敛成 trace.URLCause
五处逐字同形的三行(mediafetch.sanitizeErr / httpclient.transportCause /
vision.fetchCause / telegram.callCause / weixin_ilink.urlFreeCause)收敛为
trace 包里的唯一实现,13 个调用点改为直接调用;5 份 body 只剩 1 份,5 个
未导出符号与其文件内注释整份删除(不留薄封装)。

行为等价:只剥 *url.Error、不新增包装,内层哨兵链(context 取消/超时、
*net.DNSError 等)原样透传,因此重试/降级/熔断的分类判据不变。两处刻意的
收紧:
- 逐层剥:内层若还是 *url.Error 也一起剥(只剥一层时内层仍带同一段地址);
- *url.Error 的 Err 为 nil(或链自引用)时不再回落到原串,退化成只含 Op 的
  替身 —— 那种错误无法在不带地址的前提下原样返回,而地址里就是凭据。

顺带把 httpclient 的 user:pass@ 用例改成变异可打红:net/http 自己在
*url.Error 里就把密码打成 ***,「密码不出现」在剥壳与不剥壳下都成立(空跑),
用户名才是分水岭。

验收:go build ./... + go vet ./... + 指定包 go test -count=1 全绿;
变异验证(把 URLCause 改成透传)⇒ 五处「不含 URL」断言全红。
2026-10-26 18:45:34 +08:00

251 lines
10 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 vision
import (
"context"
"encoding/base64"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// 读取图像字节与响应体的体积上限,使恶意或
// 畸形来源无法耗尽内存。50MB 上限与
// DevDocs/02-development-constraints.md §11 的文件上传限制一致。
const (
maxImageBytes = 50 << 20
maxFetchedBody = 50 << 20
// maxErrorBodyBytes 是非 2xx 响应的读取上限。它与上面两个是**不同口径**:
// 那两个是数据面(图像字节)的预算,这一条是攻击面的预算 —— 错误体只用来
// 数长度 / 取 sha256 前缀 / 抽 error.code,因此 4 KiB 足够,绝不沿用 50 MiB
// (那会把「一个超大错误响应」变成一次内存分配与一次日志面暴露)。
maxErrorBodyBytes = 4 << 10
)
// imageFetchTimeout 限制一次图像 URL 取回的时长。适配器与消息解析器会
// 为模型代取 URL,没有上限时一个卡住或恶意的地址会一直占着这一轮
// (fetchURL 经 guardedImageClient 给未设超时的 client 补上它,
// 拨号守卫也用同一个值作单次建连上限)。
const imageFetchTimeout = 30 * time.Second
// DataURI 由 mime 与原始字节构造一个 base64 data: 图像 URI。
func DataURI(mime string, data []byte) string {
return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data)
}
// Resolve 加载一个 ImageRef 对应的字节。路径与 base64 载荷在
// 本地读取;URL 用 http.Client 抓取(传入的 client,为 nil 时
// 用默认 client)。对 base64 输入,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:
return readFile(ref.Data)
case SourceBase64:
return decodeBase64(ref.Data)
case SourceURL:
return fetchURL(ctx, ref.Data, client)
default:
return "", nil, fmt.Errorf("vision: %w: %q", ErrUnsupportedSource, ref.Source)
}
}
// readFile 读取一个图像文件。扩展名给出声明的 MIME,真实字节嗅探是否决权
// (见 imageMIMEFor):改名的 HTML 不会被当成图片读进模型。
func readFile(path string) (string, []byte, error) {
f, err := os.Open(path)
if err != nil {
return "", nil, fmt.Errorf("vision: %w: %v", ErrBadImage, err)
}
defer func() { _ = f.Close() }()
data, err := io.ReadAll(io.LimitReader(f, maxImageBytes))
if err != nil {
return "", nil, fmt.Errorf("vision: %w: %v", ErrBadImage, err)
}
mime, err := imageMIMEFor(mimeByExt(path), data)
if err != nil {
return "", nil, err
}
return mime, data, nil
}
// 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 {
// 以 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:<mime>;base64,<base64 payload>)", ErrBadImage)
}
decoded, err := base64.StdEncoding.DecodeString(src)
if err != nil {
return "", nil, fmt.Errorf("vision: %w: %v", ErrBadImage, err)
}
return "image/png", decoded, nil
}
decoded, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return "", nil, fmt.Errorf("vision: %w: %v", ErrBadImage, err)
}
return checkInlinePayload(mime, decoded)
}
// fetchURL 通过 http(s) 下载一张图像;只允许 http/https 协议。
// 取回前、重定向每一跳与真正建连前都有守卫(见 guard.go):链路本地与
// 云元数据地址被拒,环回/私网/公网照旧可达(同源自取需要它们)。
// 声明的 Content-Type 只作参考,真实字节嗅探说了算(见 imageMIMEFor):
// 网关/PROXY 返回的 HTML 错误页绝不会被当成 png 送进模型。
func fetchURL(ctx context.Context, rawURL string, client *http.Client) (string, []byte, error) {
if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") {
return "", nil, fmt.Errorf("vision: %w: %q", ErrUnsupportedSource, rawURL)
}
u, err := url.Parse(rawURL)
if err != nil {
// 不回显 URL:解析失败的原文里带着整条地址(可能含凭据)。
return "", nil, fmt.Errorf("vision: %w: URL 无法解析", ErrFetch)
}
if err := guardImageURL(u); err != nil {
return "", nil, err
}
guarded := guardedImageClient(client)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return "", nil, fmt.Errorf("vision: %w: 无法构造请求", ErrFetch)
}
resp, err := guarded.Do(req)
if err != nil {
// 被拒地址直接返回类型化错误:它只含主机与网段原因,
// 不像 client.Do 的 *url.Error 那样把整条 URL(可能含凭据)带出来。
if blocked, ok := blockedAddressOf(err); ok {
return "", nil, blocked
}
return "", nil, fmt.Errorf("vision: %w: %w", ErrFetch, trace.URLCause(err))
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return "", nil, fmt.Errorf("vision: %w: status %d", ErrFetch, resp.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, maxFetchedBody))
if err != nil {
return "", nil, fmt.Errorf("vision: %w: %v", ErrFetch, err)
}
mime, err := imageMIMEFor(resp.Header.Get("Content-Type"), data)
if err != nil {
return "", nil, err
}
return mime, data, nil
}
// inlinedImage 是一次「取回 + 内联」的产物。MIME 与 Raw 只为按内容哈希
// 缓存而返回(见 cache.go 的 imageCache),绝不写进日志。
type inlinedImage struct {
// URI 是可供模型直接消费的 data: URI。
URI string
// MIME 是归一化后的图像 MIME;data: 输入(纯短路路径)时为空。
MIME string
// Raw 是内联前的原始字节;data: 输入(纯短路路径)时为 nil。
Raw []byte
}
// inlineImageSrc 把图像来源字符串取回并内联为 data: URI,供主模型
// 直接消费。已是 data: URI 的原样返回(客户端现有的 base64 路径因此
// 逐字节不变),但**短路之前**也要过长度预检:这条路径不解码,不预检
// 就等于把体积上限完全交给入口层。URL 取回复用 Resolve→fetchURL 的既有
// 守卫(协议白名单 + 体积上限 + 地址守卫),并加取回超时。
func inlineImageSrc(ctx context.Context, src string) (string, error) {
img, err := inlineImage(ctx, src)
if err != nil {
return "", err
}
return img.URI, nil
}
// inlineImage 与 inlineImageSrc 同一条路径,但额外返回内联前的 MIME 与
// 原始字节,供 cache.go 按内容哈希 memo 化(同一 URL 不再每回合重复取回)。
func inlineImage(ctx context.Context, src string) (inlinedImage, error) {
if strings.HasPrefix(src, "data:") {
if err := checkInlineImageLength(src); err != nil {
return inlinedImage{}, err
}
return inlinedImage{URI: src}, nil
}
ref, err := ParseImageRef(src)
if err != nil {
return inlinedImage{}, err
}
if ref.Source == SourceURL {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, imageFetchTimeout)
defer cancel()
}
mime, data, err := Resolve(ctx, ref, nil)
if err != nil {
return inlinedImage{}, err
}
return inlinedImage{URI: DataURI(mime, data), MIME: mime, Raw: data}, nil
}
// splitDataURI 解析 "data:<mime>;base64,<payload>" 前缀。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
}
rest := strings.TrimPrefix(src, "data:")
comma := strings.IndexByte(rest, ',')
if comma < 0 {
return "", "", false
}
meta := rest[:comma]
idx := strings.Index(meta, ";base64")
if idx < 0 {
return "", "", false
}
mime = mediaType(meta[:idx])
if mime == "" {
mime = "image/png"
}
return mime, rest[comma+1:], true
}
// fileExists 报告 path 是否存在于磁盘且为常规文件。
func fileExists(path string) bool {
info, err := os.Stat(path)
return err == nil && info.Mode().IsRegular()
}
// defaultHTTPClient 构造 DevDocs/02-development-constraints.md §2.3
// 所述的 HTTP client。这里每次调用返回一个新 client,
// 而不是包级全局变量——后者为约束所禁止。
func defaultHTTPClient() *http.Client {
return &http.Client{Transport: http.DefaultTransport}
}