- 依赖:github.com/ledongthuc/pdf(**BSD-3-Clause**,非 MIT —— 子代理核了 LICENSE 并纠正了任务书的写法;零传递依赖;无 cgo/无外部进程;体积 strip 后 +285.5 KiB / +0.74%,在 detached worktree 里量取,产出写到仓库外)。被否候选:pdfcpu 无文本抽取、rsc.io/pdf 停更、pdfium/poppler/mupdf 需 cgo 撞红线。
- 实现:魔数 %PDF- 是唯一入口判定(名字/声明指向 PDF 而字节无魔数 ⇒ ErrUnsupported,绝不退回纯文本抽);逐页 GetPlainText(页间仅去首尾空白);ExtractorVersion v2→v3(所有 sidecar 一次性重抽);页数上限 5000 + 字节安全上限复用;新增哨兵 ErrPDFEncrypted / ErrPDFDamaged。
- 诚实边界:无文本层(扫描件)⇒ ErrNoTextLayer 且文案含「图片型/无文本层 PDF,需要 OCR(当前未做)」;加密/损坏各自可判、绝不 panic、不回空正文假装成功。
- 白名单与成对裁决:.pdf 纳入(22→23),且 ContentAllowed 做双向裁决(.pdf 只收嗅探为 application/pdf;PDF 字节只收在 .pdf 名下);读侧真 PDF 如实下发 application/pdf,仍 attachment + nosniff(不可内联)。
- 1 处 panic 兜底已如实登记:该库对「xref 指向文件外」畸形件会在 NumPage() 直接 panic(已用仓库外程序复现),单二进制常驻后端不能因 587 字节上传件崩掉 ⇒ defer recover 折成 ErrPDFDamaged;损坏类主用例仍走库的真出错路径。
- 主会话独立验收:隔离 worktree(B1 的 14 文件)build/vet 绿、全树 59 包 0 FAIL、PDF 用例 13 PASS、双平台交叉编译绿;变异独立复现(魔数裁决短路 ⇒ 魔数/成对裁决用例红)。
- ⚠️ 契约漂移(已排 C2):Docs 仍写「22 项白名单 / 嗅探仅放行 text/plain」,与本切片的 23 项 + 成对裁决不一致 ⇒ 需升 v0.61 登记 .pdf 与新的边界。
361 lines
14 KiB
Go
361 lines
14 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 项**文本类**白名单 + `.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("<!DOCTYPE html><html><body>hi</body></html>")
|
||
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("<!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)
|
||
}
|
||
}
|