- 抽共享存储核心: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,与本次无关)。
311 lines
12 KiB
Go
311 lines
12 KiB
Go
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)
|
||
}
|
||
}
|