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:;base64,)", 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, sourceOrigin(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:;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 } 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() } // sourceOrigin 给出可进错误串的「来源」形态:只有 scheme://host(见 // trace.HostOf),路径、查询串与 userinfo 一律不出 —— 一个被拒的来源地址同样 // 可能带签名 query,原样回显等于把它写进日志与工单。 // // 解析不出主机(`file:///…`、相对路径、畸形串)时回占位符而**绝不回落原串**: // 回落就是泄漏,而空串会让错误文案失去「这次拒绝的是哪一类地址」的信息。 func sourceOrigin(rawURL string) string { if origin := trace.HostOf(rawURL); origin != "" { return origin } return "" } // defaultHTTPClient 构造 DevDocs/02-development-constraints.md §2.3 // 所述的 HTTP client。这里每次调用返回一个新 client, // 而不是包级全局变量——后者为约束所禁止。 func defaultHTTPClient() *http.Client { return &http.Client{Transport: http.DefaultTransport} }