feat(documents): PDF 文本层抽取(S6 第一段;扫描件仍诚实拒绝)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 依赖: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 与新的边界。
This commit is contained in:
AskaEth committed 2026-10-04 19:46:36 +08:00
1 parent c4e09afd38
commit f82c76df27
14 files changed
+926 -94

No files matched your search

+60 -10
View File
@@ -21,19 +21,31 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
)
// whitelistExtensions 是 design/24 §9.2-2 定稿的 22 项白名单。**独立列一遍**,
// 使「白名单被悄悄改宽/改窄」这件事有用例能发现(值本身必须钉住,而不只是
// 「白名单里的项能被放行」这种自洽测试)。
// 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", ".pdf", "",
".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(默认上限)。
@@ -53,12 +65,12 @@ func expectedName(t *testing.T, ext string, data []byte) (string, string) {
return hexSum[:HashHexLen] + ext, hexSum
}
// TestStoreSaveAcceptsWhitelistedExtensions 覆盖验收第 1 条:22 项白名单逐项放行,
// TestStoreSaveAcceptsWhitelistedExtensions 覆盖验收第 1 条:23 项白名单逐项放行,
// 落盘名是内容寻址名、MIME 是嗅探基类型、内容逐字节一致。
func TestStoreSaveAcceptsWhitelistedExtensions(t *testing.T) {
t.Parallel()
if len(whitelistExtensions) != 22 {
t.Fatalf("白名单项数 = %d, want 22(design/24 §9.2-2 定稿)", len(whitelistExtensions))
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()
@@ -66,7 +78,7 @@ func TestStoreSaveAcceptsWhitelistedExtensions(t *testing.T) {
if !ExtAllowed(ext) {
t.Fatalf("ExtAllowed(%q) = false, want true(白名单缺项或拼写变了)", ext)
}
data := []byte("昔涟的测试文本 " + ext + "\n")
data := whitelistSample(t, ext)
wantName, wantSHA := expectedName(t, ext, data)
ref, err := store.Save(ctx, "orig"+ext, data)
if err != nil {
@@ -75,9 +87,13 @@ func TestStoreSaveAcceptsWhitelistedExtensions(t *testing.T) {
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 {
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, SniffOKMIME, len(data))
ext, ref.MIME, ref.Size, ref.Deduped, wantMIME, len(data))
}
stored, err := os.ReadFile(ref.Path)
if err != nil {
@@ -89,6 +105,40 @@ func TestStoreSaveAcceptsWhitelistedExtensions(t *testing.T) {
}
}
// 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) {