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

235 lines
9.1 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
import (
"errors"
"strings"
"testing"
"unicode/utf8"
)
// 这些测试钉住抽取器的对外事实(design/24 §4.1/§4.3):纯文本无损、CSV/TSV 带
// 行数/列名摘要、超预算**诚实截断**、非法 UTF-8 不 panic 也不报废、Office 与
// 二进制**诚实拒绝**(哨兵错误,用 errors.Is 判,绝不按错误文本匹配)。PDF 的
// 文本层抽取另有 extract_pdf_test.go。
// csvName / tsvName 是合法的 S1 存储名形态(32 位十六进制 + 白名单扩展名),
// 使抽取器之外的行为(sidecar 键、路径校验)与真实上传同形。
const (
csvName = "0123456789abcdef0123456789abcdef.csv"
tsvName = "0123456789abcdef0123456789abcdef.tsv"
txtName = "0123456789abcdef0123456789abcdef.txt"
pdfName = "0123456789abcdef0123456789abcdef.pdf"
)
// TestExtract_PlainTextIsLossless 验证纯文本抽取逐字无损、统计与版本随结果返回。
func TestExtract_PlainTextIsLossless(t *testing.T) {
t.Parallel()
body := "第一行\n第二行\n"
res, err := Extract(txtName, "text/plain", []byte(body))
if err != nil {
t.Fatalf("Extract: %v", err)
}
if res.Text != body {
t.Fatalf("Text = %q, want %q", res.Text, body)
}
if res.Chars != utf8.RuneCountInString(body) {
t.Fatalf("Chars = %d, want %d", res.Chars, utf8.RuneCountInString(body))
}
if res.Truncated {
t.Fatal("Truncated = true, want false")
}
if res.Kind != kindText {
t.Fatalf("Kind = %q, want %q", res.Kind, kindText)
}
if res.ExtractorVersion != ExtractorVersion {
t.Fatalf("ExtractorVersion = %q, want %q", res.ExtractorVersion, ExtractorVersion)
}
}
// TestExtract_CSVPrependsRowAndColumnSummary 钉住 CSV 特例(design/24 §4.3):
// 正文前必须有「行数/列名」摘要,否则模型会拿抽到的片段回答「一共多少行」。
func TestExtract_CSVPrependsRowAndColumnSummary(t *testing.T) {
t.Parallel()
body := "id,name\n1,alice\n2,bob\n"
res, err := Extract(csvName, "text/plain", []byte(body))
if err != nil {
t.Fatalf("Extract: %v", err)
}
if res.Kind != kindCSV {
t.Fatalf("Kind = %q, want %q", res.Kind, kindCSV)
}
summary, rest, ok := strings.Cut(res.Text, "\n")
if !ok {
t.Fatalf("Text 缺少摘要行: %q", res.Text)
}
for _, want := range []string{"共 3 行(含表头)", "2 列", "id", "name"} {
if !strings.Contains(summary, want) {
t.Fatalf("摘要 %q 缺少 %q", summary, want)
}
}
if rest != body {
t.Fatalf("摘要之后的正文 = %q, want %q", rest, body)
}
if res.Chars != utf8.RuneCountInString(res.Text) {
t.Fatalf("Chars = %d, want %d", res.Chars, utf8.RuneCountInString(res.Text))
}
}
// TestExtract_TSVPrependsSummary 验证 TSV 与 CSV 同一条特例路径(制表符切列)。
func TestExtract_TSVPrependsSummary(t *testing.T) {
t.Parallel()
res, err := Extract(tsvName, "text/plain", []byte("a\tb\n1\t2\n"))
if err != nil {
t.Fatalf("Extract: %v", err)
}
if res.Kind != kindTSV {
t.Fatalf("Kind = %q, want %q", res.Kind, kindTSV)
}
if !strings.HasPrefix(res.Text, "[TSV 摘要] 共 2 行(含表头),2 列:a, b\n") {
t.Fatalf("Text = %q, want 以 TSV 摘要行开头", res.Text)
}
}
// TestExtract_TruncatesAtSafetyCap 钉住「绝不静默截断」(design/24 §4.3):
// 撞上**安全上限**(MaxSidecarBytes,8 MiB 文本)时截到上限并置 Truncated,且截断
// 是 rune 安全的(多字节字符不被切成两半)。
//
// 注意它钉的不再是「注入预算」:注入预算由装配期按上下文预算决定(切片 T1 §7.2),
// 抽取器只保证 sidecar 里存得下全文、且撞上上限时如实记账。
func TestExtract_TruncatesAtSafetyCap(t *testing.T) {
t.Parallel()
body := strings.Repeat("你好", MaxSidecarBytes/len("你好")+16)
res, err := Extract(txtName, "text/plain", []byte(body))
if err != nil {
t.Fatalf("Extract: %v", err)
}
if !res.Truncated {
t.Fatal("Truncated = false, want true")
}
if len(res.Text) > MaxSidecarBytes {
t.Fatalf("截断后正文 %d 字节,超出安全上限 %d", len(res.Text), MaxSidecarBytes)
}
if res.Chars != utf8.RuneCountInString(res.Text) {
t.Fatalf("Chars = %d, want 正文 rune 数 %d", res.Chars, utf8.RuneCountInString(res.Text))
}
if !utf8.ValidString(res.Text) {
t.Fatal("截断后的正文不是合法 UTF-8")
}
// 上限内的正文逐字无损:截断只砍尾巴,不改写已有内容。
if !strings.HasPrefix(body, res.Text) {
t.Fatal("截断后的正文不是原文的前缀")
}
}
// TestExtract_KeepsFullTextBelowSafetyCap 钉住切片 T1 §7.2 的核心:抽取期**不再**
// 按注入预算截断,因此一份远超旧 64K 字符、但仍在安全上限内的文档必须整份存下 ——
// 否则 document-read 永远读不到注入预算之外的正文(S4 实测的断裂)。
func TestExtract_KeepsFullTextBelowSafetyCap(t *testing.T) {
t.Parallel()
body := strings.Repeat("宽", 200*1024) // 20 万字符:远超旧的 64K 注入预算
res, err := Extract(txtName, "text/plain", []byte(body))
if err != nil {
t.Fatalf("Extract: %v", err)
}
if res.Truncated {
t.Fatal("Truncated = true, want false(安全上限内的正文不该被截断)")
}
if res.Chars != 200*1024 || res.Text != body {
t.Fatalf("Chars = %d(want %d),正文是否逐字无损 = %v", res.Chars, 200*1024, res.Text == body)
}
}
// TestExtract_SanitizesInvalidUTF8 钉住「非法 UTF-8 要处理,不可 panic」:
// 非法字节被替换为 U+FFFD,正文其余部分照常抽出。
func TestExtract_SanitizesInvalidUTF8(t *testing.T) {
t.Parallel()
data := append([]byte("前缀"), 0xff, 0xfe)
data = append(data, []byte("后缀")...)
res, err := Extract(txtName, "text/plain", data)
if err != nil {
t.Fatalf("Extract: %v", err)
}
if !utf8.ValidString(res.Text) {
t.Fatalf("Text 不是合法 UTF-8: %q", res.Text)
}
if !strings.Contains(res.Text, utf8Replacement) {
t.Fatalf("Text = %q, want 含替换字符 %q", res.Text, utf8Replacement)
}
if !strings.HasPrefix(res.Text, "前缀") || !strings.HasSuffix(res.Text, "后缀") {
t.Fatalf("Text = %q, want 保留前后缀", res.Text)
}
}
// TestExtract_EmptyFileIsNotAnError 钉住「空正文」与「读不到」的区别:一份真正
// 空的可读文本不是错误(错误会让上游把「读到了空文件」报成 unreadable)。
func TestExtract_EmptyFileIsNotAnError(t *testing.T) {
t.Parallel()
res, err := Extract(txtName, "text/plain", nil)
if err != nil {
t.Fatalf("Extract(empty) = %v, want nil", err)
}
if res.Text != "" || res.Chars != 0 || res.Truncated {
t.Fatalf("Result = %+v, want 空正文且未截断", res)
}
}
// TestExtract_RefusesUnsupportedContent 钉住主路径①的诚实拒绝:二进制内容
// (探测窗口里有 NUL)返回 ErrUnsupported,而不是空正文 + nil 错误。
func TestExtract_RefusesUnsupportedContent(t *testing.T) {
t.Parallel()
data := []byte{'P', 'K', 0x03, 0x04, 0x00, 0x00, 'x'}
if _, err := Extract(txtName, "application/zip", data); !errors.Is(err, ErrUnsupported) {
t.Fatalf("Extract(zip) = %v, want ErrUnsupported", err)
}
if _, err := Extract(txtName, "text/plain", data); !errors.Is(err, ErrUnsupported) {
t.Fatalf("Extract(binary) = %v, want ErrUnsupported", err)
}
}
// TestExtract_RefusesNoTextLayer 钉住 Office 与「声明像是 PDF 却不是」的诚实拒绝:
// Office 扩展名或嗅探所得 MIME 指向该类格式即 ErrNoTextLayer;而**名字/声明指向
// PDF、字节却不是真 PDF** 时是「说的和做的不一致」,按不支持拒绝 —— 两条都不允许
// 退回按纯文本抽(PDF 文本层本身能抽,用例见 extract_pdf_test.go)。
func TestExtract_RefusesNoTextLayer(t *testing.T) {
t.Parallel()
if _, err := Extract("report.docx", "text/plain", []byte("docx")); !errors.Is(err, ErrNoTextLayer) {
t.Fatalf("Extract(.docx) = %v, want ErrNoTextLayer", err)
}
if _, err := Extract("sheet.xlsx", "text/plain", []byte("xlsx")); !errors.Is(err, ErrNoTextLayer) {
t.Fatalf("Extract(.xlsx) = %v, want ErrNoTextLayer", err)
}
if _, err := Extract(txtName, "application/msword", []byte("doc")); !errors.Is(err, ErrUnsupported) {
t.Fatalf("Extract(word mime) = %v, want ErrUnsupported", err)
}
// 扩展名看着像 PDF、内容却是纯文本:绝不能按文本抽(那种内容会以「正文」
// 身份进模型上下文),也不能报成「无文本层」。
if _, err := Extract(pdfName, "text/plain", []byte("hi")); !errors.Is(err, ErrUnsupported) {
t.Fatalf("Extract(.pdf as text) = %v, want ErrUnsupported", err)
}
// 扩展名看着像文本、嗅探结果却是 PDF、字节又不是真 PDF:同样拒绝。
if _, err := Extract(txtName, PDFMIME, []byte("hi")); !errors.Is(err, ErrUnsupported) {
t.Fatalf("Extract(pdf mime) = %v, want ErrUnsupported", err)
}
}
// TestCountLines 钉住行数口径:空正文 0 行、结尾换行不额外算一行。
func TestCountLines(t *testing.T) {
t.Parallel()
cases := []struct {
in string
want int
}{
{"", 0},
{"a", 1},
{"a\n", 1},
{"a\nb", 2},
{"a\nb\n", 2},
{"\n", 1},
}
for _, c := range cases {
if got := countLines(c.in); got != c.want {
t.Fatalf("countLines(%q) = %d, want %d", c.in, got, c.want)
}
}
}