Files
AskaEth f82c76df27
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(documents): PDF 文本层抽取(S6 第一段;扫描件仍诚实拒绝)
- 依赖: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 与新的边界。
2026-10-04 19:46:36 +08:00

361 lines
14 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}