refactor+feat(documents,channelbridge): 共享存储核心 + IM 入站附件摄取(主人 2.3)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 抽共享存储核心:internal/runtime/documents/store.go(22 项白名单 + 字节嗅探唯一裁决 + 内容寻址 sha256[:32]+ext + 去重 + 限额 + 路径双检)+ store_meta.go(documents 表唯一写入实现)。

- gateway 改为调用 store,**对外行为零变化**(机器证明:既有 documents_test/chat_documents_test/chat_documents_bounds_test 三个文件 0 行改动且全绿;状态码/响应字段/错误码/审计动作名/限额文档口径全部逐字保留;maxChatDocuments 改为引用 documents.MaxPerMessage)。

- IM 入站附件摄取(channelbridge):文件类媒体 → store.Save → 挂 model.DocumentMessage(RoleUser) 部件 ⇒ 后续 resolveDocuments 自动抽取/注入/清单;挂载点在「会话解析 + 黑白名单 + 最小打扰」三重闸门之后(被拦消息绝不在盘上留文件);**不支持的格式不静默**(诚实提示行 + 带来源日志,只记 name/mime/size,名字做展示净化防伪造行);图片/语音保持既有链路。

- 主会话独立验收:隔离 worktree(M1 的 12 文件)build/vet 绿、全树 59 包 0 FAIL、双平台交叉编译绿、12 条 Inbound 用例 PASS;变异独立复现(关闭 IM 摄取 ⇒ 5 条红,含「落盘文件数 = [] want 1」)。

- ⚠️ 真机边界(需裁决):现网四个适配器上报的文件媒体**只带 URL**(telegram FileID / qq-obv11 file|url / weixin·kook CDN URL),Media.Data 恒空 ⇒ 本切片只摄**内联字节**,URL 一律「诚实提示,不代取远程」(避免第三份 SSRF 实现)。真机要让「她读到 IM 文件」,需各适配器入站时下载并内联(凭据在各自渠道里,推荐)或新增共享受守卫抓取器。

- 另登记:IM 附件暂未写 chat_messages.document 列(二手回合看不到引用,建议并入 S7);channelbridge 既有测试夹具在 -race 下有数据竞争(stubSessions.GetOrCreate,与本次无关)。
This commit is contained in:
AskaEth committed 2026-10-04 19:25:00 +08:00
1 parent 51c1672355
commit 2a43035f5d
12 files changed
+1743 -158

No files matched your search

+349
View File
@@ -0,0 +1,349 @@
package documents
// 本文件归属**文档存储核心**(design/24 §3.2;切片 M1):把 S1 原本长在网关
// `internal/services/gateway/documents.go` 里的存储规则(字节嗅探唯一裁决 +
// 扩展名白名单 + 内容寻址 + 去重 + 限额 + 元数据)抽成本包的**一份**实现,
// 供两条来源路径共用:
//
// - Web 上传(`POST /api/v1/documents`,网关调用);
// - IM 入站附件(channelbridge 把消息里的文件类媒体交给这里,切片 M1)。
//
// 为什么必须先抽出来:规则里有安全裁决(嗅探否决、白名单、路径双检),在桥接器
// 里再写一套必然漂移;而 `<DataDir>/uploads/documents` 与 `documents` 元数据表
// 也必须是**唯一写入面**,S7 的引用计数与 GC 才有单一事实来源。
//
// 落盘规则与 S1 逐字同源:客户端原始文件名**永不参与路径**,落盘名是
// `sha256(bytes)[:32] + ext`,同字节同扩展名必然同名、天然去重;目录 0700、
// 文件 0600(继承临时文件)。读侧(`GET /api/v1/documents/{name}`)与装配期
// 摄取(Resolver)不改,仍旧只认这个形态的名字。
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
)
const (
// DefaultMaxBytes 是文档单文件字节的**默认上限**(20 MiB,即
// `CHAT_DOCUMENT_MAX_BYTES` 的默认值)。它只在这里定义一次:网关的
// `maxDocumentBytes`、配置的 default 标签与 IM 入站附件都从它派生,绝不
// 在别处再写一个数(design/24 §3.3:文档上限与图片上限刻意解耦)。
DefaultMaxBytes = 20 << 20
// MaxPerMessage 是**单条消息**(一个回合)可携带的附件数上限
// (design/24 §9.4-3 的定稿值 5)。网关的单回合引用上限与 IM 入站附件
// 的上限同源于它。
MaxPerMessage = 5
// HashHexLen 是内容寻址落盘名使用的小写十六进制主名长度(32 位十六进制
// = 128 位)。读侧的名字形态校验与 sidecar 的键同名同宽。
HashHexLen = 32
// SniffOKMIME 是唯一放行的嗅探结果**基类型**。白名单里每一种文档(文本、
// 代码、CSV、JSON、YAML…)经 `http.DetectContentType` 嗅探后都落在
// `text/plain`;HTML/XML/SVG/压缩包/可执行文件/判不定者各有自己的签名,
// 因此「不是 text/plain」即「拒绝」。
SniffOKMIME = "text/plain"
// tempPattern 是原子落盘用的临时文件前缀(点前缀,绝不与内容寻址名混淆)。
tempPattern = ".document-*"
// dirMode 是文档目录权限;文件权限继承 `os.CreateTemp` 的 0600。
dirMode = 0o700
// uploadsSubDir / documentSubDir 是 `<DataDir>` 之下的默认布局:
// `<DataDir>/uploads/documents`。与网关的 `documentSubDir`、组合根给摄取层
// 的 UploadsDir 同源,改一处必须同步(否则装配期会去读一个不存在的目录)。
uploadsSubDir = "uploads"
documentSubDir = "documents"
)
// 存储侧的哨兵错误。调用方一律用 `errors.Is` 分类,**绝不按错误文本匹配**:
// 网关把它们映射为既有的 415 / 413 / 400 契约错误码,IM 侧映射为诚实提示。
var (
// ErrExtNotAllowed 报告原始文件名的扩展名不在白名单内(deny-by-default)。
ErrExtNotAllowed = errors.New("documents: 扩展名不在白名单内")
// ErrContentNotText 报告字节嗅探否决了这份内容(基类型不是 text/plain)。
ErrContentNotText = errors.New("documents: 内容不是纯文本类(字节嗅探否决)")
// ErrTooLarge 报告内容超过生效的单文件字节上限。
ErrTooLarge = errors.New("documents: 内容超过字节上限")
// ErrEmpty 报告内容为空(空文件没有落盘意义,也不该产生元数据行)。
ErrEmpty = errors.New("documents: 内容为空")
// ErrNoDir 报告文档落盘目录未配置(组合根/网关都没给出 UploadsDir 与 DataDir)。
ErrNoDir = errors.New("documents: 文档落盘目录未配置")
// ErrBadPath 报告拼出的落盘路径逃出了文档目录(内容寻址名由本包签发,
// 因此这是防御性的第二道门)。
ErrBadPath = errors.New("documents: 落盘路径逃出文档目录")
// ErrMetadata 报告元数据写入失败。它与「文件已经落盘」是两个事实:调用方
// 据它把 500 文案分开(S1 的「记录文档元数据失败」),并知道盘上那份文件
// 需要 S7 的 GC 容忍或清理(design/24 §9.2-5)。
ErrMetadata = errors.New("documents: 元数据写入失败")
)
// Ref 是一次成功落盘的结果:服务端签发的引用 + 落盘路径。前五个字段是调用方
// 对外呈现的事实(网关的响应字段、IM 侧的展示身份),Path 只给服务端自己用
// (日志/清理),**绝不进模型上下文、绝不回给客户端**。
type Ref struct {
// Name 是内容寻址的存储名(`sha256(bytes)[:32] + ext`)。
Name string
// MIME 是字节嗅探所得的基类型(去参数、小写)。
MIME string
// Size 是落盘字节数。
Size int64
// SHA256 是完整内容的十六进制摘要(落盘名只取前 HashHexLen 位)。
SHA256 string
// Deduped 报告这次写入命中了盘上已有的同一份内容(同字节同扩展名)。
Deduped bool
// Path 是落盘绝对/相对路径(按构造方给的 UploadsDir 决定形态)。
Path string
}
// Record 是写入 `documents` 表(迁移 0014)的一行元数据。列与 S1 逐字一致。
type Record struct {
// Name 是内容寻址的存储名(表上的唯一键)。
Name string
// SHA256 是完整内容的十六进制摘要。
SHA256 string
// MIME 是嗅探所得的基类型。
MIME string
// Size 是字节数。
Size int64
// Uploader 是上传者身份:网关给请求 claims 的 sub,IM 给渠道来源标签。
// 身份**只由构造方给出**,store 绝不从文件名/内容里猜(项目红线)。
Uploader string
}
// MetadataWriter 是元数据写入的**可选注入面**(design/24 §9.2-5 的技术债:
// 网关当前每次上传 `db.Open` 开新连接,本切片不把它改成连接注入)。
// 未注入时 Save 照旧成功,只跳过写表并留一条 DEBUG 痕迹。
type MetadataWriter interface {
// WriteDocument 写入一行文档元数据。同一存储名重复上传由实现方
// `ON CONFLICT(name) DO NOTHING`(首次的 uploader/created_at 是权威)。
WriteDocument(ctx context.Context, rec Record) error
}
// StoreConfig 是文档存储核心的接线参数。路径口径由调用方给出,本包不猜数据
// 目录布局(与摄取层 Config 同一理由)。
type StoreConfig struct {
// DataDir 是数据目录。UploadsDir 留空时文档落进
// `<DataDir>/uploads/documents`(网关与组合根用的是同一布局)。
DataDir string
// UploadsDir 是文档落盘的**完整目录**。非空时优先于 DataDir 推导。
UploadsDir string
// MaxBytes 是单文件字节上限;≤0 时取 DefaultMaxBytes。口径是
// 「≤0 = 用默认」而不是「禁用上限」,与网关的 documentLimitBytes 一致。
MaxBytes int
// Uploader 是写进元数据的上传者身份(见 Record.Uploader)。
Uploader string
// Meta 是元数据写入的可选注入面;nil 时跳过写表并留痕。
Meta MetadataWriter
// Log 是基础 logger;nil 时替换为标记来源 runtime/documents 的丢弃 logger。
Log *slog.Logger
}
// Store 是文档落盘的唯一实现。它可安全并发使用(无可变状态,落盘的竞态由
// 「临时文件 + rename」与「已存在即去重」处理)。
type Store struct {
dir string
maxBytes int
uploader string
meta MetadataWriter
log *slog.Logger
}
// NewStore 构造文档存储。UploadsDir 与 DataDir 都为空 ⇒ 每次 Save 都返回
// ErrNoDir(宁可明确失败,也不猜一个目录出来)。
func NewStore(cfg StoreConfig) *Store {
dir := strings.TrimSpace(cfg.UploadsDir)
if dir == "" {
if root := strings.TrimSpace(cfg.DataDir); root != "" {
dir = filepath.Join(root, uploadsSubDir, documentSubDir)
}
}
return &Store{
dir: dir,
maxBytes: cfg.MaxBytes,
uploader: strings.TrimSpace(cfg.Uploader),
meta: cfg.Meta,
log: sourcedLog(cfg.Log),
}
}
// Dir 返回生效的文档落盘目录(未配置时为空串)。它给测试与日志用,不参与落盘。
func (s *Store) Dir() string { return s.dir }
// Limit 返回本次生效的单文件字节上限(≤0 归一为 DefaultMaxBytes)。
func (s *Store) Limit() int {
if s.maxBytes <= 0 {
return DefaultMaxBytes
}
return s.maxBytes
}
// Save 把一份原始文件落进文档存储,返回服务端签发的引用。规则与 S1 逐字同源,
// 判定顺序也刻意保持(扩展名 → 空 → 上限 → 嗅探),因为「同一次上传里两条规则
// 都不过时给哪个错误」是对外可见的行为:
//
// 1. **扩展名白名单**(deny-by-default):原始文件名的扩展名不在放行集合内一律
// 拒绝(`ErrExtNotAllowed`)—— 嗅探成 `text/plain` 的 `.js`/`.py` 也在此列;
// 2. **空内容**拒绝(`ErrEmpty`);
// 3. **字节上限**拒绝(`ErrTooLarge`,上限见 Limit);
// 4. **字节嗅探唯一裁决**:`http.DetectContentType` 的基类型必须是 `text/plain`,
// 否则拒绝(`ErrContentNotText`)—— 客户端声明的 MIME 与文件名只作参考、
// 可被否决;
// 5. 内容寻址落盘 + 路径双检 + 去重(同字节同扩展名 ⇒ 同名 ⇒ `Deduped=true`),
// 随后(可选)写一行 `documents` 元数据。
//
// 元数据写入失败时返回的错误同时满足 `errors.Is(err, ErrMetadata)`,且**已落盘的
// 引用照旧返回**:文件确实在盘上,调用方据此如实上报(S1 的行为)。
func (s *Store) Save(ctx context.Context, origName string, data []byte) (Ref, error) {
sniffed := http.DetectContentType(data)
ref, err := s.prepare(origName, sniffed, data)
if err != nil {
return Ref{}, err
}
committed, err := s.commit(ref, data)
if err != nil {
return Ref{}, err
}
if err := s.record(ctx, committed); err != nil {
return committed, err
}
return committed, nil
}
// prepare 执行落盘之前的全部裁决并算出内容寻址引用(不触盘、不建目录)。
// sniffed 由调用方传入,使「嗅探发生在任何磁盘动作之前」这一顺序在代码里可见。
func (s *Store) prepare(origName, sniffed string, data []byte) (Ref, error) {
ext := ExtOf(origName)
if !ExtAllowed(ext) {
return Ref{}, fmt.Errorf("documents: save: %w", ErrExtNotAllowed)
}
if len(data) == 0 {
return Ref{}, fmt.Errorf("documents: save: %w", ErrEmpty)
}
if limit := s.Limit(); len(data) > limit {
return Ref{}, fmt.Errorf("documents: save: 上限 %d 字节,本次 %d 字节: %w", limit, len(data), ErrTooLarge)
}
if !SniffAllowed(sniffed) {
return Ref{}, fmt.Errorf("documents: save: 嗅探 %q 非纯文本: %w", baseMIME(sniffed), ErrContentNotText)
}
sum := sha256.Sum256(data)
return Ref{
Name: hashName(sum[:], ext),
MIME: baseMIME(sniffed),
Size: int64(len(data)),
SHA256: hex.EncodeToString(sum[:]),
}, nil
}
// commit 把字节落进 `<dir>/<name>`:先写点前缀临时文件再 rename(部分写入绝不会
// 以可读的名字露面),目标名已存在时丢弃临时文件并报告 deduped —— 盘上只留一份。
func (s *Store) commit(ref Ref, data []byte) (Ref, error) {
if s.dir == "" {
return Ref{}, fmt.Errorf("documents: commit: %w", ErrNoDir)
}
path, err := targetPath(s.dir, ref.Name)
if err != nil {
return Ref{}, err
}
if info, serr := os.Stat(path); serr == nil && !info.IsDir() {
ref.Path, ref.Deduped = path, true
return ref, nil
}
if err := os.MkdirAll(s.dir, dirMode); err != nil {
return Ref{}, fmt.Errorf("documents: 建目录: %w", err)
}
tmp, err := os.CreateTemp(s.dir, tempPattern)
if err != nil {
return Ref{}, fmt.Errorf("documents: 建临时文件: %w", err)
}
tmpName := tmp.Name()
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
_ = os.Remove(tmpName)
return Ref{}, fmt.Errorf("documents: 写临时文件: %w", err)
}
if err := tmp.Close(); err != nil {
_ = os.Remove(tmpName)
return Ref{}, fmt.Errorf("documents: 关临时文件: %w", err)
}
if err := os.Rename(tmpName, path); err != nil {
_ = os.Remove(tmpName)
return Ref{}, fmt.Errorf("documents: 提交落盘: %w", err)
}
ref.Path = path
return ref, nil
}
// record 写入一行 `documents` 元数据(可选注入面)。未注入时跳过并留痕:
// 文件已经落盘,元数据留待接线(S1 在 `dbPath == ""` 时的行为)。
func (s *Store) record(ctx context.Context, ref Ref) error {
if s.meta == nil {
s.log.Debug("document metadata skipped: writer not wired",
"name", ref.Name, "size", ref.Size, "deduped", ref.Deduped)
return nil
}
rec := Record{
Name: ref.Name, SHA256: ref.SHA256, MIME: ref.MIME,
Size: ref.Size, Uploader: s.uploader,
}
if err := s.meta.WriteDocument(ctx, rec); err != nil {
return fmt.Errorf("documents: 元数据 %q: %w: %w", ref.Name, ErrMetadata, err)
}
return nil
}
// targetPath 按落盘名拼出路径并做 S1 的**路径双检**:拼出的路径必须仍在文档
// 目录内。名字由本包签发(内容寻址),因此这是防御性的第二道门 —— 与读侧
// `filepath.Dir(path) != filepath.Clean(dir)` 是同一手法。
func targetPath(dir, name string) (string, error) {
path := filepath.Join(dir, name)
if filepath.Dir(path) != filepath.Clean(dir) {
return "", fmt.Errorf("documents: 落盘名 %q: %w", name, ErrBadPath)
}
return path, nil
}
// ExtOf 从原始文件名取出小写扩展名(含点)。它只作**参考**:结果必须同时通过
// 扩展名白名单与字节嗅探,客户端因此无法靠改扩展名换类型。
func ExtOf(filename string) string {
return strings.ToLower(filepath.Ext(filename))
}
// ExtAllowed 报告扩展名是否在放行集合内。deny-by-default:集合之外的一切
// (`.html`/`.svg`/`.xml`/`.zip`/`.docx`/`.xlsx`/`.exe`/`.dll`/`.bat`/`.cmd`/
// `.ps1`/`.js`/`.mjs`/`.sh`/`.py`…)一律拒绝。注意 `.js`/`.py` 即便嗅探成
// `text/plain` 也必须拒绝 —— 判定以扩展名与嗅探的**合取**为准。
//
// 这 22 项是 design/24 §9.2-2 的定稿集合,全仓只此一份(网关的名字形态校验
// 与 IM 入站附件都经它判定)。
func ExtAllowed(ext string) bool {
switch ext {
case ".txt", ".md", ".markdown", ".csv", ".tsv", ".json", ".jsonl",
".yaml", ".yml", ".log", ".ini", ".cfg", ".toml",
".go", ".ts", ".tsx", ".java", ".c", ".h", ".cpp", ".rs", ".sql":
return true
default:
return false
}
}
// SniffAllowed 报告嗅探所得类型是否落在放行的文本类内。它是「类型裁决的唯一
// 裁决者」在代码里的落点:客户端声明的 `Content-Type` 从不参与决定。
func SniffAllowed(sniffed string) bool {
return baseMIME(sniffed) == SniffOKMIME
}
// hashName 返回内容寻址的落盘名:sha256 十六进制的前 HashHexLen 位 + ext。
// 同一份字节配同一个扩展名必然得到同一个名字,去重因此是「同名」的自然结果。
func hashName(sum []byte, ext string) string {
return hex.EncodeToString(sum)[:HashHexLen] + ext
}
+62
View File
@@ -0,0 +1,62 @@
package documents
// 本文件归属 `documents` 元数据表(迁移 0014)的**唯一写入实现**(design/24
// §3.2;切片 M1)。S1 把这条 SQL 写在网关里;抽到共享存储核心之后,Web 上传与
// IM 入站附件必须共用同一份写入逻辑,否则「哪条来源写了元数据、哪条没写」会
// 分叉,S7 的引用计数与 GC 也就失去单一事实来源。
//
// 沿用 S1 的两条既有事实(刻意不改,改动属另一片):
// - 每次写入 `db.Open` 开一条新连接(design/24 §9.2-5 已登记的技术债);
// - 同一存储名重复上传 `ON CONFLICT(name) DO NOTHING` —— 首次的
// uploader/created_at 是权威,去重不刷新它们。
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
)
// ErrMetadataNoPath 报告元数据写入器没有数据库路径(未接库的构建/测试)。
// 调用方据此不构造写入器(Store.Meta 留 nil),而不是拿到一个写不进去的替身。
var ErrMetadataNoPath = errors.New("documents: 元数据写入器缺少数据库路径")
// SQLMetadataWriter 把元数据行写进 SQLite 的 `documents` 表。它实现
// MetadataWriter,供网关(Web 上传)与组合根(IM 入站附件)共用。
type SQLMetadataWriter struct {
path string
log *slog.Logger
}
// MetadataWriterFor 按数据库路径给出元数据写入面;路径为空(或全空白)时返回
// **真正的 nil 接口**,调用方可以直接把它放进 `StoreConfig.Meta` 而不会踩
// 「接口里装着一个 nil 具体值」的陷阱(那会让「未注入」判定失效,把一次成功的
// 落盘变成 500)。未接库的构建/测试因此照旧可用:Store 按「未注入」跳过并留痕。
func MetadataWriterFor(path string, log *slog.Logger) MetadataWriter {
if strings.TrimSpace(path) == "" {
return nil
}
return &SQLMetadataWriter{path: path, log: log}
}
// WriteDocument 写入一行文档元数据。参数化 SQL、无拼接,错误按项目规范 wrap。
func (w *SQLMetadataWriter) WriteDocument(ctx context.Context, rec Record) error {
if w == nil || w.path == "" {
return fmt.Errorf("documents: 写元数据: %w", ErrMetadataNoPath)
}
conn, err := db.Open(ctx, w.path, w.log)
if err != nil {
return fmt.Errorf("documents: 打开元数据库: %w", err)
}
defer func() { _ = conn.Close() }()
const query = `INSERT INTO documents (name, sha256, mime, size, uploader)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(name) DO NOTHING`
if _, err := conn.ExecContext(ctx, query, rec.Name, rec.SHA256, rec.MIME, rec.Size, rec.Uploader); err != nil {
return fmt.Errorf("documents: 插入元数据行: %w", err)
}
return nil
}
+310
View File
@@ -0,0 +1,310 @@
package documents
// 本文件锁定**文档存储核心**(design/24 §3.2;切片 M1)的规则:白名单放行/拒绝、
// 字节嗅探否决客户端声明、内容寻址去重、限额、路径穿越、元数据落表。规则与 S1
// 逐字同源(`internal/services/gateway/documents.go` 的既有断言是另一侧的机器证明:
// 网关改成调用本实现之后,那批断言必须原样全绿)。
//
// 断言一律走哨兵错误 + errors.Is,**不按错误文本子串断言** —— 文案会变,契约不变。
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
)
// whitelistExtensions 是 design/24 §9.2-2 定稿的 22 项白名单。**独立列一遍**,
// 使「白名单被悄悄改宽/改窄」这件事有用例能发现(值本身必须钉住,而不只是
// 「白名单里的项能被放行」这种自洽测试)。
var whitelistExtensions = []string{
".txt", ".md", ".markdown", ".csv", ".tsv", ".json", ".jsonl",
".yaml", ".yml", ".log", ".ini", ".cfg", ".toml",
".go", ".ts", ".tsx", ".java", ".c", ".h", ".cpp", ".rs", ".sql",
}
// deniedExtensions 是必须被拒的扩展名(含「嗅探成 text/plain 也不放行」的脚本类)。
var deniedExtensions = []string{
".html", ".svg", ".xml", ".zip", ".docx", ".xlsx", ".exe", ".dll",
".bat", ".cmd", ".ps1", ".js", ".mjs", ".sh", ".py", ".pdf", "",
}
// newTestStore 构造一个落在临时目录的 Store(默认上限)。
func newTestStore(t *testing.T, cfg StoreConfig) *Store {
t.Helper()
if cfg.UploadsDir == "" && cfg.DataDir == "" {
cfg.UploadsDir = filepath.Join(t.TempDir(), uploadsSubDir, documentSubDir)
}
return NewStore(cfg)
}
// expectedName 复算一份内容在给定扩展名下的期望落盘名(内容寻址口径的独立实现)。
func expectedName(t *testing.T, ext string, data []byte) (string, string) {
t.Helper()
sum := sha256.Sum256(data)
hexSum := hex.EncodeToString(sum[:])
return hexSum[:HashHexLen] + ext, hexSum
}
// TestStoreSaveAcceptsWhitelistedExtensions 覆盖验收第 1 条:22 项白名单逐项放行,
// 落盘名是内容寻址名、MIME 是嗅探基类型、内容逐字节一致。
func TestStoreSaveAcceptsWhitelistedExtensions(t *testing.T) {
t.Parallel()
if len(whitelistExtensions) != 22 {
t.Fatalf("白名单项数 = %d, want 22(design/24 §9.2-2 定稿)", len(whitelistExtensions))
}
store := newTestStore(t, StoreConfig{})
ctx := context.Background()
for _, ext := range whitelistExtensions {
if !ExtAllowed(ext) {
t.Fatalf("ExtAllowed(%q) = false, want true(白名单缺项或拼写变了)", ext)
}
data := []byte("昔涟的测试文本 " + ext + "\n")
wantName, wantSHA := expectedName(t, ext, data)
ref, err := store.Save(ctx, "orig"+ext, data)
if err != nil {
t.Fatalf("%s: Save: %v", ext, err)
}
if ref.Name != wantName || ref.SHA256 != wantSHA {
t.Fatalf("%s: name/sha = (%q,%q), want (%q,%q)", ext, ref.Name, ref.SHA256, wantName, wantSHA)
}
if ref.MIME != SniffOKMIME || ref.Size != int64(len(data)) || ref.Deduped {
t.Fatalf("%s: mime/size/deduped = (%q,%d,%v), want (%q,%d,false)",
ext, ref.MIME, ref.Size, ref.Deduped, SniffOKMIME, len(data))
}
stored, err := os.ReadFile(ref.Path)
if err != nil {
t.Fatalf("%s: 盘上读不到 %s: %v", ext, ref.Path, err)
}
if !bytes.Equal(stored, data) {
t.Fatalf("%s: 落盘内容与输入不一致", ext)
}
}
}
// TestStoreSaveRejectsDisallowedExtensions 覆盖验收第 1 条的拒绝侧:deny-by-default,
// 含 `.js`/`.py`/`.sh` 这类「内容确是纯文本但扩展名不在白名单」的样本。
func TestStoreSaveRejectsDisallowedExtensions(t *testing.T) {
t.Parallel()
if ExtAllowed("") {
t.Fatal("ExtAllowed(\"\") = true, want false(无扩展名必须拒绝)")
}
store := newTestStore(t, StoreConfig{})
ctx := context.Background()
for _, ext := range deniedExtensions {
if ExtAllowed(ext) {
t.Fatalf("ExtAllowed(%q) = true, want false", ext)
}
if _, err := store.Save(ctx, "sample"+ext, []byte("const a = 1;\n")); !errors.Is(err, ErrExtNotAllowed) {
t.Fatalf("%s: err = %v, want ErrExtNotAllowed", ext, err)
}
}
if entries, err := os.ReadDir(store.Dir()); err == nil && len(entries) != 0 {
t.Fatalf("被拒的扩展名仍在盘上留下 %d 个文件", len(entries))
}
}
// TestStoreSaveSniffVetoesContent 覆盖验收第 1 条的嗅探侧:文件名与内容不符
// (PNG / HTML / zip 字节顶着 `.txt`)时,字节嗅探是唯一裁决者,一律拒绝。
func TestStoreSaveSniffVetoesContent(t *testing.T) {
t.Parallel()
store := newTestStore(t, StoreConfig{})
ctx := context.Background()
png := append([]byte("\x89PNG\r\n\x1a\n"), bytes.Repeat([]byte{0x00}, 64)...)
zip := append([]byte("PK\x03\x04"), bytes.Repeat([]byte{0x00}, 64)...)
cases := []struct {
label string
data []byte
}{
{"PNG", png},
{"HTML", []byte("<!DOCTYPE html><html><body>hi</body></html>")},
{"zip", zip},
{"NUL 字节", append([]byte("text\x00more"), bytes.Repeat([]byte{0x01}, 32)...)},
}
for _, tc := range cases {
if _, err := store.Save(ctx, "lie.txt", tc.data); !errors.Is(err, ErrContentNotText) {
t.Fatalf("%s: err = %v, want ErrContentNotText", tc.label, err)
}
}
if _, err := os.Stat(store.Dir()); !os.IsNotExist(err) {
t.Fatalf("被嗅探否决的写入仍创建了文档目录(err=%v)", err)
}
}
// TestStoreSaveDeduplicatesByContent 覆盖验收第 1 条的去重侧:同字节同扩展名
// ⇒ 同名 + Deduped=true + 盘上只留一份;换客户端文件名不影响落盘名。
func TestStoreSaveDeduplicatesByContent(t *testing.T) {
t.Parallel()
store := newTestStore(t, StoreConfig{})
ctx := context.Background()
data := []byte("同一份内容的两次上传\n")
first, err := store.Save(ctx, "first.txt", data)
if err != nil {
t.Fatalf("首次 Save: %v", err)
}
if first.Deduped {
t.Fatal("首次 Save 报告 Deduped = true")
}
second, err := store.Save(ctx, "renamed.txt", data)
if err != nil {
t.Fatalf("重复 Save: %v", err)
}
if !second.Deduped || second.Name != first.Name || second.Path != first.Path {
t.Fatalf("重复 Save = (%q,%v,%q), want 与首次同名同路径且 Deduped", second.Name, second.Deduped, second.Path)
}
entries, err := os.ReadDir(store.Dir())
if err != nil {
t.Fatalf("读文档目录: %v", err)
}
if len(entries) != 1 {
t.Fatalf("盘上文件数 = %d, want 1(同字节同扩展名只留一份)", len(entries))
}
}
// TestStoreSaveEnforcesLimit 覆盖验收第 1 条的限额侧:≤0 归一到 DefaultMaxBytes、
// 恰好等于上限放行、多一个字节拒绝。
func TestStoreSaveEnforcesLimit(t *testing.T) {
t.Parallel()
const limit = 1024
if got := NewStore(StoreConfig{}).Limit(); got != DefaultMaxBytes {
t.Fatalf("未配置上限时 Limit() = %d, want %d", got, DefaultMaxBytes)
}
store := newTestStore(t, StoreConfig{MaxBytes: limit})
ctx := context.Background()
if _, err := store.Save(ctx, "exact.txt", bytes.Repeat([]byte("a"), limit)); err != nil {
t.Fatalf("恰好等于上限被拒: %v", err)
}
if _, err := store.Save(ctx, "over.txt", bytes.Repeat([]byte("b"), limit+1)); !errors.Is(err, ErrTooLarge) {
t.Fatalf("超限一个字节: err = %v, want ErrTooLarge", err)
}
if _, err := store.Save(ctx, "empty.txt", nil); !errors.Is(err, ErrEmpty) {
t.Fatalf("空内容: err = %v, want ErrEmpty", err)
}
if _, err := NewStore(StoreConfig{}).Save(ctx, "no.txt", []byte("x")); !errors.Is(err, ErrNoDir) {
t.Fatalf("未配置目录: err = %v, want ErrNoDir", err)
}
}
// TestStoreSaveNeverUsesOriginalName 覆盖验收第 1 条的路径侧:客户端原始文件名
// (含 `..`、分隔符、绝对路径)**永不参与落盘路径**,落盘名恒为内容寻址名。
func TestStoreSaveNeverUsesOriginalName(t *testing.T) {
t.Parallel()
root := t.TempDir()
dir := filepath.Join(root, uploadsSubDir, documentSubDir)
store := newTestStore(t, StoreConfig{UploadsDir: dir})
ctx := context.Background()
names := []string{
"../../escape.txt",
`..\..\escape.txt`,
"/etc/passwd.txt",
"nested/dir/name.txt",
"..txt",
}
for _, origName := range names {
ref, err := store.Save(ctx, origName, []byte("路径穿越样本 "+origName+"\n"))
if err != nil {
t.Fatalf("%q: Save: %v", origName, err)
}
if filepath.Dir(ref.Path) != filepath.Clean(dir) {
t.Fatalf("%q: 落盘路径 %q 逃出了文档目录 %q", origName, ref.Path, dir)
}
if ref.Name != filepath.Base(ref.Name) || strings.ContainsAny(ref.Name, `/\`) {
t.Fatalf("%q: 落盘名 %q 不是单个路径段", origName, ref.Name)
}
if ext := ExtOf(origName); !strings.HasSuffix(ref.Name, ext) {
t.Fatalf("%q: 落盘名 %q 丢了扩展名 %q", origName, ref.Name, ext)
}
}
// 整个数据目录里只应出现文档目录与文档目录下的文件,别处没有逃逸产物。
roots, err := os.ReadDir(root)
if err != nil {
t.Fatalf("读数据目录: %v", err)
}
for _, e := range roots {
if e.Name() != uploadsSubDir {
t.Fatalf("数据目录下出现了意外条目 %q(路径穿越没有收敛在文档目录内)", e.Name())
}
}
}
// TestStoreSaveWritesMetadataRow 覆盖验收第 1 条的元数据侧:注入写入器时
// `documents` 表确有对应行(name/sha256/mime/size/uploader),重复上传
// `ON CONFLICT DO NOTHING` 不新增第二行;未注入时跳过但不影响落盘。
func TestStoreSaveWritesMetadataRow(t *testing.T) {
t.Parallel()
dataDir := t.TempDir()
dbPath := filepath.Join(dataDir, "store.db")
conn, err := db.Open(context.Background(), dbPath, nil)
if err != nil {
t.Fatalf("打开临时库: %v", err)
}
if err := db.MigrateUp(context.Background(), conn, nil); err != nil {
_ = conn.Close()
t.Fatalf("临时库跑迁移: %v", err)
}
if err := conn.Close(); err != nil {
t.Fatalf("关闭临时库: %v", err)
}
if MetadataWriterFor("", nil) != nil {
t.Fatal("空路径应得到 nil 接口(调用方据此不接线)")
}
if MetadataWriterFor(" ", nil) != nil {
t.Fatal("空白路径应得到 nil 接口")
}
store := newTestStore(t, StoreConfig{
DataDir: dataDir,
Uploader: "acct",
Meta: MetadataWriterFor(dbPath, nil),
})
ctx := context.Background()
data := []byte("元数据行断言\n")
ref, err := store.Save(ctx, "meta.txt", data)
if err != nil {
t.Fatalf("Save: %v", err)
}
if _, err := store.Save(ctx, "meta-again.txt", data); err != nil {
t.Fatalf("重复 Save: %v", err)
}
check, err := db.Open(ctx, dbPath, nil)
if err != nil {
t.Fatalf("复开临时库: %v", err)
}
defer func() { _ = check.Close() }()
var name, sha, mimeType, uploader string
var size int64
if err := check.QueryRowContext(ctx,
`SELECT name, sha256, mime, size, uploader FROM documents WHERE name = ?`, ref.Name).
Scan(&name, &sha, &mimeType, &size, &uploader); err != nil {
t.Fatalf("documents 表里没有对应元数据行: %v", err)
}
if name != ref.Name || sha != ref.SHA256 || mimeType != SniffOKMIME || size != int64(len(data)) || uploader != "acct" {
t.Fatalf("元数据行 = (%q,%q,%q,%d,%q), want 与 Ref 一致且 uploader=acct", name, sha, mimeType, size, uploader)
}
var rows int
if err := check.QueryRowContext(ctx, `SELECT COUNT(*) FROM documents`).Scan(&rows); err != nil {
t.Fatalf("数元数据行: %v", err)
}
if rows != 1 {
t.Fatalf("documents 行数 = %d, want 1(同内容重复上传不新增行)", rows)
}
// 未注入写入器:落盘照旧成功,只跳过写表。
plain := newTestStore(t, StoreConfig{UploadsDir: filepath.Join(t.TempDir(), documentSubDir)})
ref2, err := plain.Save(ctx, "nowriter.md", []byte("# 无元数据\n"))
if err != nil {
t.Fatalf("未注入写入器时 Save: %v", err)
}
if _, err := os.Stat(ref2.Path); err != nil {
t.Fatalf("未注入写入器时文件没落盘: %v", err)
}
}