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 项**文本类**白名单 + `.pdf` // (切片 B1 纳入,共 23 项)。**独立列一遍**,使「白名单被悄悄改宽/改窄」这件事 // 有用例能发现(值本身必须钉住,而不只是「白名单里的项能被放行」这种自洽测试)。 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", PDFExt, } // deniedExtensions 是必须被拒的扩展名(含「嗅探成 text/plain 也不放行」的脚本类)。 var deniedExtensions = []string{ ".html", ".svg", ".xml", ".zip", ".docx", ".xlsx", ".exe", ".dll", ".bat", ".cmd", ".ps1", ".js", ".mjs", ".sh", ".py", "", } // whitelistSample 给每个白名单扩展名一份**该类型真实成立**的样本字节:文本类用 // 一行 UTF-8 文本,`.pdf` 用现场拼出的合法 PDF(`.pdf` 只收真 PDF,占位文本会被 // 成对裁决拒掉 —— 那正是切片 B1 的行为)。 func whitelistSample(t *testing.T, ext string) []byte { t.Helper() if ext == PDFExt { return buildPDF(t, pdfFixture{Contents: []string{textContent("whitelist sample")}}) } return []byte("昔涟的测试文本 " + ext + "\n") } // 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 条:23 项白名单逐项放行, // 落盘名是内容寻址名、MIME 是嗅探基类型、内容逐字节一致。 func TestStoreSaveAcceptsWhitelistedExtensions(t *testing.T) { t.Parallel() if len(whitelistExtensions) != 23 { t.Fatalf("白名单项数 = %d, want 23(design/24 §9.2-2 的 22 项文本类 + .pdf)", 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 := whitelistSample(t, ext) 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) } wantMIME := SniffOKMIME if ext == PDFExt { wantMIME = PDFMIME } if ref.MIME != wantMIME || 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, wantMIME, 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) } } } // TestStoreSavePDFPairing 覆盖验收第 4 条的反向:`.pdf` **只收真 PDF**,真 PDF 也 // 只收在 `.pdf` 名下 —— 把 HTML 改名成 `.pdf` 仍被字节嗅探否决;限额对 `.pdf` // 同样生效(判定顺序仍是 上限 → 嗅探)。 func TestStoreSavePDFPairing(t *testing.T) { t.Parallel() store := newTestStore(t, StoreConfig{}) ctx := context.Background() html := []byte("hi") if _, err := store.Save(ctx, "lie.pdf", html); !errors.Is(err, ErrContentNotText) { t.Fatalf("HTML 改名成 .pdf: err = %v, want ErrContentNotText", err) } if _, err := store.Save(ctx, "blank.pdf", []byte("只是一段普通文本\n")); !errors.Is(err, ErrContentNotText) { t.Fatalf("文本改名成 .pdf: err = %v, want ErrContentNotText", err) } pdfBytes := buildPDF(t, pdfFixture{Contents: []string{textContent("real pdf")}}) if _, err := store.Save(ctx, "doc.txt", pdfBytes); !errors.Is(err, ErrContentNotText) { t.Fatalf("PDF 改名成 .txt: err = %v, want ErrContentNotText", err) } ref, err := store.Save(ctx, "report.pdf", pdfBytes) if err != nil { t.Fatalf("真 PDF 上传: %v", err) } if ref.MIME != PDFMIME || !strings.HasSuffix(ref.Name, PDFExt) { t.Fatalf("真 PDF 的落盘 = (%q,%q), want (%q,%s 结尾)", ref.Name, ref.MIME, PDFMIME, PDFExt) } const limit = 1024 small := newTestStore(t, StoreConfig{MaxBytes: limit}) over := append([]byte(pdfMagic), bytes.Repeat([]byte{'a'}, limit)...) if _, err := small.Save(ctx, "big.pdf", over); !errors.Is(err, ErrTooLarge) { t.Fatalf("超限 PDF: err = %v, want ErrTooLarge", err) } } // 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("hi")}, {"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) } }