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

+1
View File
@@ -6,6 +6,7 @@ require (
github.com/google/jsonschema-go v0.4.3 github.com/google/jsonschema-go v0.4.3
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
github.com/jackc/pgx/v5 v5.10.0 github.com/jackc/pgx/v5 v5.10.0
github.com/ledongthuc/pdf v0.0.0-20260907135840-6c8c28e0e8a0
github.com/modelcontextprotocol/go-sdk v1.8.0 github.com/modelcontextprotocol/go-sdk v1.8.0
github.com/robfig/cron/v3 v3.0.1 github.com/robfig/cron/v3 v3.0.1
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 github.com/santhosh-tekuri/jsonschema/v5 v5.3.1
+2
View File
@@ -30,6 +30,8 @@ github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/ledongthuc/pdf v0.0.0-20260907135840-6c8c28e0e8a0 h1:7Q+xNAZFmnfYOMweHN3c/PDFUKKfY1pVJ26K++QvVfU=
github.com/ledongthuc/pdf v0.0.0-20260907135840-6c8c28e0e8a0/go.mod h1:1fEHWurg7pvf5SG6XNE5Q8UZmOwex51Mkx3SLhrW5B4=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/modelcontextprotocol/go-sdk v1.8.0 h1:KIvahhYqwtbeniWVPs3TcXEA7b8jEtwfBpOTAI+Urx4= github.com/modelcontextprotocol/go-sdk v1.8.0 h1:KIvahhYqwtbeniWVPs3TcXEA7b8jEtwfBpOTAI+Urx4=
+49 -23
View File
@@ -1,10 +1,12 @@
// Package documents 实现文档摄取层(design/24 §4):把一份已落盘的文本类文档 // Package documents 实现文档摄取层(design/24 §4):把一份已落盘的文档
// 抽成可注入模型可见上下文的正文,并把结果缓存进 sidecar(只抽一次)。 // 抽成可注入模型可见上下文的正文,并把结果缓存进 sidecar(只抽一次)。
// //
// 本期只处理纯文本类(design/24 §1.2 的 P0):抽取是**纯函数**、零新依赖、 // 本期处理**纯文本类**(design/24 §1.2 的 P0)与 **PDF 的文本层**(切片 B1,
// 不触网、不落盘。PDF/Office 按 §8-1 的默认裁决**诚实拒绝**(返回 // design/24 §7 的 S6 第一段):抽取是**纯函数**、不触网、不落盘(sidecar 由
// ErrNoTextLayer):绝不假装成功,也绝不返回「空正文 + nil 错误」——后者会让 // 装配期写)。Office(.doc/.docx/.xls/.xlsx/.ppt/.pptx/.odt/.ods/.odp/.rtf)与
// 上游把「读不到」误当成「读到了空文件」,正是 F2 要消灭的那种误解。 // **图片型/扫描件 PDF** 按 §8-1 的裁决**诚实拒绝**(返回 `ErrNoTextLayer`,
// 文案说清「需要 OCR」):绝不假装成功,也绝不返回「空正文 + nil 错误」——后者
// 会让上游把「读不到」误当成「读到了空文件」,正是 F2 要消灭的那种误解。
package documents package documents
import ( import (
@@ -26,7 +28,11 @@ const (
// //
// v2(切片 T1 §7.2):抽取期**不再**按注入预算截断,只在安全上限 // v2(切片 T1 §7.2):抽取期**不再**按注入预算截断,只在安全上限
// (MaxSidecarBytes)处截断 —— 旧缓存里的正文可能少了一大截,必须重抽。 // (MaxSidecarBytes)处截断 —— 旧缓存里的正文可能少了一大截,必须重抽。
ExtractorVersion = "documents/v2" //
// v3(切片 B1):**PDF 文本层开始能抽**(原先一律诚实拒绝),且失败原因的
// 分类与文案全变了(无文本层/加密/损坏各自成哨兵)—— 旧缓存里的 unreadable
// 结论已经是错的,必须重抽。
ExtractorVersion = "documents/v3"
// MaxSidecarBytes 是 sidecar 正文的**安全上限**(字节,8 MiB 文本)。 // MaxSidecarBytes 是 sidecar 正文的**安全上限**(字节,8 MiB 文本)。
// //
@@ -49,10 +55,11 @@ const (
// 目的是挡住被误存进来的二进制,不是做通用格式识别。 // 目的是挡住被误存进来的二进制,不是做通用格式识别。
binaryProbeBytes = 512 binaryProbeBytes = 512
// kindText / kindCSV / kindTSV 是 Result.Kind 的取值。 // kindText / kindCSV / kindTSV / kindPDF 是 Result.Kind 的取值。
kindText = "text" kindText = "text"
kindCSV = "csv" kindCSV = "csv"
kindTSV = "tsv" kindTSV = "tsv"
kindPDF = "pdf"
// csvDelim / tsvDelim 是摘要切列名用的分隔符。 // csvDelim / tsvDelim 是摘要切列名用的分隔符。
csvDelim = "," csvDelim = ","
@@ -75,9 +82,11 @@ var (
// ErrUnsupported 报告内容不在可抽取的纯文本范围内(二进制、非文本类型)。 // ErrUnsupported 报告内容不在可抽取的纯文本范围内(二进制、非文本类型)。
ErrUnsupported = errors.New("documents: 内容不在可抽取的纯文本范围内") ErrUnsupported = errors.New("documents: 内容不在可抽取的纯文本范围内")
// ErrNoTextLayer 报告这是一份需要文本层解析的格式(PDF/Office):本期 // ErrNoTextLayer 报告这份内容的格式需要文本层,而我们读不出文本层:一类是
// 按 design/24 §8-1 诚实拒绝,不引解析库(单二进制体积与失败面)。 // **图片型/扫描件 PDF**(它本就没有文本层,要先栅格化再 OCR,纯 Go 没有可靠
ErrNoTextLayer = errors.New("documents: 无文本层(PDF/Office 本期不支持解析)") // 的栅格化实现 —— 切片 B1 的口径),另一类是本期不解析的 **Office**
// (.doc/.docx/.xls/.xlsx/.ppt/.pptx/.odt/.ods/.odp/.rtf)。
ErrNoTextLayer = errors.New("documents: 无文本层(图片型/扫描件 PDF 需 OCR;Office 本期不支持解析)")
) )
// Result 是一次抽取的产物。它是抽取器的全部对外事实:正文、字符数、是否 // Result 是一次抽取的产物。它是抽取器的全部对外事实:正文、字符数、是否
@@ -92,8 +101,11 @@ type Result struct {
// 它与「注入被截断」是两件事:后者由装配期按注入预算判定并写进 // 它与「注入被截断」是两件事:后者由装配期按注入预算判定并写进
// history.DocumentRef(切片 T1 §7.2)。 // history.DocumentRef(切片 T1 §7.2)。
Truncated bool Truncated bool
// Kind 是抽取出内容的种类(text / csv / tsv)。 // Kind 是抽取出内容的种类(text / csv / tsv / pdf)。
Kind string Kind string
// Pages 是 PDF 的页数(其它种类恒为 0)。它随 sidecar 元数据落盘,用来让
// 「这份 PDF 有多大」这件事在缓存里可见(切片 B1)。
Pages int
// ExtractorVersion 是产出本结果的抽取器版本。 // ExtractorVersion 是产出本结果的抽取器版本。
ExtractorVersion string ExtractorVersion string
} }
@@ -101,20 +113,36 @@ type Result struct {
// Extract 把一份文档的字节抽成可注入的正文。它是**纯函数**:不读盘、不写盘、 // Extract 把一份文档的字节抽成可注入的正文。它是**纯函数**:不读盘、不写盘、
// 不依赖任何外部状态,因此 sidecar 命中与未命中得到逐字相同的结果。 // 不依赖任何外部状态,因此 sidecar 命中与未命中得到逐字相同的结果。
// //
// name 是服务端存储名(扩展名决定 CSV/TSV 特例与办公格式的诚实拒绝),mime 是 // name 是服务端存储名(扩展名决定 CSV/TSV 特例与 Office 的诚实拒绝),mime 是
// 服务端字节嗅探所得:按 design/24 §3.2 的口径它只作**否决**参考,绝不让一份 // 服务端字节嗅探所得:按 design/24 §3.2 的口径它只作**否决**参考,绝不让一份
// 不被认可的文档变成可抽取。 // 不被认可的文档变成可抽取。
// //
// 返回的错误一律是上面两个哨兵错误的包装;绝不 panic,也绝不返回「空正文 + // 分派顺序(切片 B1 起):
// 1. **魔数 `%PDF-`** → 走 PDF 文本层抽取(`extract_pdf.go`),扩展名与 MIME
// 都不参与 —— 字节说了算,与存储侧的字节嗅探同源;
// 2. 名字/嗅探指向 PDF、字节却不是真 PDF → 明确拒绝(绝不退回按纯文本抽);
// 3. Office 扩展名 → `ErrNoTextLayer`;
// 4. 非纯文本声明、明显二进制 → `ErrUnsupported`;
// 5. 其余按纯文本抽(CSV/TSV 另加摘要行)。
//
// 返回的错误一律是上面那些哨兵错误的包装;绝不 panic,也绝不返回「空正文 +
// nil 错误」。 // nil 错误」。
func Extract(name, mime string, data []byte) (Result, error) { func Extract(name, mime string, data []byte) (Result, error) {
ext := strings.ToLower(filepath.Ext(name)) ext := strings.ToLower(filepath.Ext(name))
base := baseMIME(mime)
if hasPDFMagic(data) {
return extractPDF(data)
}
if ext == PDFExt || base == PDFMIME {
// 「说得像 PDF、字节却不是」:这是**声明与内容不一致**,按不支持拒绝。
// 绝不能退回按纯文本抽 —— 那等于让一份改名成 .pdf 的 HTML/脚本以
// 「文本」身份进模型上下文。
return Result{}, fmt.Errorf("documents: extract %s (mime %q): 缺少 PDF 魔数: %w", ext, base, ErrUnsupported)
}
if noTextLayerExt(ext) { if noTextLayerExt(ext) {
return Result{}, fmt.Errorf("documents: extract %s: %w", ext, ErrNoTextLayer) return Result{}, fmt.Errorf("documents: extract %s: %w", ext, ErrNoTextLayer)
} }
if base := baseMIME(mime); base == pdfMIME { if base != "" && !textualMIME(base) {
return Result{}, fmt.Errorf("documents: extract mime %q: %w", base, ErrNoTextLayer)
} else if base != "" && !textualMIME(base) {
return Result{}, fmt.Errorf("documents: extract mime %q: %w", base, ErrUnsupported) return Result{}, fmt.Errorf("documents: extract mime %q: %w", base, ErrUnsupported)
} }
if looksBinary(data) { if looksBinary(data) {
@@ -137,12 +165,13 @@ func Extract(name, mime string, data []byte) (Result, error) {
return res, nil return res, nil
} }
// noTextLayerExt 报告扩展名是否属于**诚实拒绝**的办公/文档格式(design/24 // noTextLayerExt 报告扩展名是否属于**读不到文本层**的格式(design/24 §8-1 的
// §8-1 的默认裁决):它们的正文在压缩流或文本层里,本期不引解析库,因此明确 // 默认裁决):**Office 一族**的正文在压缩流或文本层里,本期不引解析库,因此明确
// 报告读不到,而不是把二进制塞进上下文。 // 报告读不到,而不是把二进制塞进上下文。PDF **不在**这里 —— 它的文本层由
// `extract_pdf.go` 真抽(抽不到文本层的图片型 PDF 由那条路径给出 ErrNoTextLayer)。
func noTextLayerExt(ext string) bool { func noTextLayerExt(ext string) bool {
switch ext { switch ext {
case ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", case ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
".odt", ".ods", ".odp", ".rtf": ".odt", ".ods", ".odp", ".rtf":
return true return true
default: default:
@@ -150,9 +179,6 @@ func noTextLayerExt(ext string) bool {
} }
} }
// pdfMIME 是拒绝对应的嗅探基类型。
const pdfMIME = "application/pdf"
// baseMIME 把一个媒体类型归一为基类型(去参数、小写)。S1 的存储早已只落 // baseMIME 把一个媒体类型归一为基类型(去参数、小写)。S1 的存储早已只落
// 基类型,这里只是不让带参数的输入影响判定。 // 基类型,这里只是不让带参数的输入影响判定。
func baseMIME(mt string) string { func baseMIME(mt string) string {
+192
View File
@@ -0,0 +1,192 @@
package documents
// 本文件实现 **PDF 文本层**抽取(切片 B1;design/24 §4 路线①、§7 的 S6 第一段)。
//
// 能力边界(主人 2026-10-04 夜裁决):
// - **文本层 PDF 能读、且要读**:读出文档对象图与页内容流里的文本算子
// (`Tj` / `TJ` / `'` / `"`),把正文交给既有的 sidecar 链路,与纯文本类
// 走同一条注入路径;
// - **扫描件/图片型 PDF 读不了**:那类文件没有文本层,必须先栅格化再 OCR,而
// 纯 Go 没有可靠的栅格化实现(pdfium/poppler/mupdf 都是 cgo 或原生库)⇒ 会撞
// 「单二进制 + linux/darwin 交叉编译」与「零跨进程 IPC」两条红线 ⇒ **本切片
// 不做**;但拒绝文案必须说清「这是图片型/无文本层 PDF,需要 OCR 才能读」。
//
// 判定只用**魔数**(文件头 `%PDF-`),不看文件名、也不看声明的 MIME —— 与全项目
// 「字节嗅探是唯一裁决」同源(存储侧的 `ContentAllowed` 是同一条口径的另一半)。
//
// 失败一律显式、且**分类可判**(`errors.Is`):无文本层 → `ErrNoTextLayer`、
// 加密打不开 → `ErrPDFEncrypted`、损坏/截断/版本不受支持 → `ErrPDFDamaged`。
// **绝不 panic、绝不返回空正文假装成功**(design/24 §4.4)。
//
// 依赖:`github.com/ledongthuc/pdf`(纯 Go、零传递依赖、BSD-3-Clause「Go Authors」;
// 见 `LICENSE` 原文与切片 B1 记录的许可/体积增量)。无 cgo、无外部进程,因此
// `GOOS=linux/darwin` 交叉编译照旧,也不触「单二进制 + 零跨进程 IPC」两条红线。
import (
"bytes"
"errors"
"fmt"
"strings"
"unicode/utf8"
"github.com/ledongthuc/pdf"
)
// PDF 抽取的两个哨兵错误。调用方(摄取层把结论折成注入模型的原因短句)一律用
// `errors.Is` 分类,绝不按错误文本匹配 —— 与 `ErrUnsupported` / `ErrNoTextLayer`
// 同一口径。
var (
// ErrPDFEncrypted 报告这是一份加密 PDF,且用空口令打不开。
ErrPDFEncrypted = errors.New("documents: PDF 已加密(空口令无法打开)")
// ErrPDFDamaged 报告 PDF 损坏、被截断,或它的版本/结构不被解析器接受。
ErrPDFDamaged = errors.New("documents: PDF 损坏或无法解析")
)
const (
// pdfMagic 是 PDF 的魔数(文件头前 5 字节)。判定只看字节本身。
pdfMagic = "%PDF-"
// maxPDFPages 是单份 PDF 的**页数上限**(安全阀):页数来自 trailer 的
// `/Count`,畸形文件可以声明成任意大;没有这个上限,一份几百字节的恶意
// PDF 就能让抽取循环跑上亿次。抽到上限即停,并置 Truncated —— 与撞上
// `MaxSidecarBytes` 同一语义:「正文到此为止」绝不等于「原文件到此为止」。
maxPDFPages = 5000
// pdfPageJoin 分隔页与页之间的正文。
pdfPageJoin = "\n"
// pdfTrailerProbeBytes 是「加密判定」的扫描窗口:PDF 的 trailer(或 xref
// 流对象)一定在文件末尾附近,`/Encrypt` 键因此落在这最后一段里。
pdfTrailerProbeBytes = 2048
// pdfEncryptKey 是 trailer 里标记加密的键名。
pdfEncryptKey = "/Encrypt"
)
// hasPDFMagic 报告字节流是否以 PDF 魔数开头。它是本包对 PDF 的**唯一入口判定**:
// 扩展名与声明的 MIME 都不参与(与存储侧的字节嗅探同一裁决口径)。
func hasPDFMagic(data []byte) bool {
return bytes.HasPrefix(data, []byte(pdfMagic))
}
// extractPDF 把一份 PDF 的文本层抽成正文。data 是整份文件字节(调用方已按上限
// 读过一遍)。
//
// 失败分类:无文本层(整份文件一页正文都没有)→ `ErrNoTextLayer`;加密 →
// `ErrPDFEncrypted`;其余(格式非法、截断、页对象图坏掉)→ `ErrPDFDamaged`。
func extractPDF(data []byte) (res Result, err error) {
// 解析库用 panic 传递内部解析错误,并在它的 NewReader / GetPlainText 上把
// panic 收成 error —— **但遍历类接口(NumPage / Page)本身没有收**,畸形
// xref 能把 panic 直接抛到调用方。单二进制后端绝不能因为一份上传文件整个
// 进程崩掉,因此这里再兜一层:兜到的 panic 一律按「损坏」如实上报,
// **绝不吞成成功**(拒绝路径仍然是真出错路径,见本文件顶部的失败口径)。
defer func() {
if rec := recover(); rec != nil {
res = Result{}
err = fmt.Errorf("documents: pdf 解析器 panic: %v: %w", rec, ErrPDFDamaged)
}
}()
reader, err := pdf.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return Result{}, pdfOpenError(data, err)
}
total := reader.NumPage()
if total <= 0 {
// 声明 0 页或页树读不出:这不是「没文本层」(那是扫描件),而是文件本身
// 不成立 —— 归为损坏,绝不报成「需要 OCR」。
return Result{}, fmt.Errorf("documents: pdf 声明页数 %d: %w", total, ErrPDFDamaged)
}
window, pageCapped := pdfPageWindow(total)
body, sizeCapped, err := pdfText(reader, window)
if err != nil {
return Result{}, err
}
if strings.TrimSpace(body) == "" {
return Result{}, fmt.Errorf("documents: pdf 共 %d 页无文本层: %w", total, ErrNoTextLayer)
}
res = Result{
Kind: kindPDF,
Pages: total,
Text: body,
Truncated: pageCapped || sizeCapped,
ExtractorVersion: ExtractorVersion,
}
if len(res.Text) > MaxSidecarBytes {
res.Text = prefixBytes(res.Text, MaxSidecarBytes)
res.Truncated = true
}
res.Chars = utf8.RuneCountInString(res.Text)
return res, nil
}
// pdfPageWindow 把声明的页数收敛到安全上限内,并报告是否被收敛(被收敛即
// 「正文只抽了前 maxPDFPages 页」,抽取结论必须如实标记 Truncated)。
func pdfPageWindow(total int) (int, bool) {
if total > maxPDFPages {
return maxPDFPages, true
}
return total, false
}
// pdfText 逐页抽正文,页与页之间以换行分隔。命中正文安全上限(MaxSidecarBytes)
// 即停止并报告 capped:既不让一份超大 PDF 把堆撑爆,也绝不静默丢掉后面的页。
//
// 页序号从 1 起(解析库的口径)。某一页解析失败就整份报告损坏 —— 「抽到一半
// 的正文」配不上「这份 PDF 读得到」这个结论,宁可诚实失败也不给半份正文。
func pdfText(reader *pdf.Reader, pages int) (string, bool, error) {
var body strings.Builder
capped := false
for i := 1; i <= pages; i++ {
if body.Len() >= MaxSidecarBytes {
capped = true
break
}
text, err := reader.Page(i).GetPlainText(nil)
if err != nil {
return "", false, fmt.Errorf("documents: pdf 第 %d 页: %w: %w", i, ErrPDFDamaged, err)
}
// 逐页去掉首尾空白:解析库在文本对象边界会补换行,不整理就会在页之间
// 摞出空行;页内文本本身一字不动。整页只有空白 ⇒ 该页没有文本层,跳过。
text = strings.TrimSpace(text)
if text == "" {
continue
}
if body.Len() > 0 {
body.WriteString(pdfPageJoin)
}
body.WriteString(text)
}
return cleanPDFText(body.String()), capped, nil
}
// cleanPDFText 让抽出的正文满足本包的正文口径:合法 UTF-8(非法字节替换为
// U+FFFD,绝不因编码问题失败或 panic),并去掉 NUL 字节(PDF 字符串允许 NUL,
// 它进模型可见文本只是噪声)。
func cleanPDFText(s string) string {
return strings.ReplaceAll(sanitizeUTF8(s), "\x00", "")
}
// pdfOpenError 把解析器的打开错误折成两个哨兵之一:空口令打不开的加密文件是
// 「加密」,其余(缺 `%%EOF`、`startxref`/xref 读不出、头非法、版本不受支持)
// 是「损坏」。
//
// 解析器只对「口令不对」给了哨兵(`pdf.ErrInvalidPassword`),AES-256 一类
// **加密方式不受支持**的现代加密 PDF 只回一条普通错误;因此补一次 trailer 判据,
// 免得把「加密」如实报成「损坏」(两种失败对用户的下一步动作完全不同)。
func pdfOpenError(data []byte, err error) error {
if errors.Is(err, pdf.ErrInvalidPassword) || pdfTrailerMentionsEncrypt(data) {
return fmt.Errorf("documents: pdf 打开: %w: %w", ErrPDFEncrypted, err)
}
return fmt.Errorf("documents: pdf 打开: %w: %w", ErrPDFDamaged, err)
}
// pdfTrailerMentionsEncrypt 报告文件末尾的 trailer 区是否声明了 `/Encrypt`。
// 它只是**辅助判据**(仅在打开失败后用来区分加密与损坏),因此宁可宽一点:
// 尾部出现过该键名即认作加密,判错也只会把分类偏向「加密」那一侧。
func pdfTrailerMentionsEncrypt(data []byte) bool {
tail := data
if len(tail) > pdfTrailerProbeBytes {
tail = tail[len(tail)-pdfTrailerProbeBytes:]
}
return bytes.Contains(tail, []byte(pdfEncryptKey))
}
@@ -0,0 +1,337 @@
package documents
// 本文件锁定 **PDF 文本层抽取**(切片 B1)的对外事实:
//
// - 真 PDF(带文本层)按**魔数**认出并抽出正文(不靠扩展名、不靠声明 MIME);
// - 图片型/无文本层 PDF **诚实拒绝**(ErrNoTextLayer,文案说清「需要 OCR」);
// - 损坏/截断/畸形 xref ⇒ 明确错误、**不 panic**(真出错路径,不是 recover 兜出来的);
// - 加密 ⇒ 独立的哨兵(ErrPDFEncrypted,与「损坏」可区分);
// - 正文仍受安全上限(MaxSidecarBytes)与页数上限约束,且截断**如实标记**。
//
// 夹具 PDF **全部由 buildPDF 现场拼出**(不下载任何二进制):对象体是字面量,
// xref 的字节偏移由函数自己算准 —— 这既是「手写的极简合法 PDF」,也让每个用例
// 想要什么结构就拼什么结构(多页 / 无文本层 / 加密 / 断 xref)。
//
// 夹具正文一律用 ASCII:解析库对未声明 /ToUnicode 的字体按标准编码逐字节映射,
// 多字节字符在那里必然走样 —— 那是抽取库的能力边界,不是本包的口径,因此不在
// 这里断言(本包对抽出的正文只保证「合法 UTF-8」,见 cleanPDFText 的用例)。
import (
"bytes"
"errors"
"fmt"
"strings"
"testing"
"unicode/utf8"
)
// pdfFixture 描述一份手工拼出来的极简 PDF(单页或多页)。
type pdfFixture struct {
// Contents 是各页的**完整内容流**(一页一条),用 textContent/imageContent 拼。
Contents []string
// DeclaredCount 覆盖 Pages 的 `/Count`(0 = 取 len(Contents)):用来造
// 「声明的页数与实际不符 / 声称 0 页」这类畸形件。
DeclaredCount int
// Encrypted 为真时追加一个 /Encrypt 对象并连进 trailer(加密件)。
Encrypted bool
// BreakXref 为真时把对象 1 的 xref 偏移写成 999999(指向文件之外),
// 造出「xref 表能读、指向的对象读不出来」这一类畸形 —— 解析器会在遍历
// 页树时抛 panic,正是本包必须兜住、且必须如实报成「损坏」的形态。
BreakXref bool
}
// textContent 拼一段「画一行文本」的页内容流(唯一的文本算子路径)。
func textContent(s string) string {
return "BT /F1 18 Tf 72 720 Td (" + pdfEscape(s) + ") Tj ET"
}
// imageContent 拼一段**不含任何文本算子**的页内容流:矩形填充。它就是
// 「图片型/扫描件页」在解析器眼里的样子(有页、有内容流、但抽不出文本)。
const imageContent = "q 0 0 1 rg 100 100 200 200 re f Q"
// pdfEscape 转义 PDF 字面量字符串里的三个特殊字符。
func pdfEscape(s string) string {
r := strings.NewReplacer(`\`, `\\`, `(`, `\(`, `)`, `\)`)
return r.Replace(s)
}
// buildPDF 把夹具拼成一份**合法**的 PDF 字节流:对象体 + 算准偏移的 xref 表 +
// trailer + startxref + %%EOF,头是 `%PDF-1.4`(解析库只接受 1.0–1.7 的头)。
func buildPDF(t *testing.T, fix pdfFixture) []byte {
t.Helper()
count := fix.DeclaredCount
if count == 0 {
count = len(fix.Contents)
}
// 对象号:1 = Catalog、2 = Pages、3..= Page 们、之后 = Font、各页内容流、
// 最后(可选)= Encrypt。
fontID := 3 + len(fix.Contents)
firstContentID := fontID + 1
kids := make([]string, 0, len(fix.Contents))
for i := range fix.Contents {
kids = append(kids, fmt.Sprintf("%d 0 R", 3+i))
}
objs := make([]string, 0, firstContentID+len(fix.Contents)+1)
objs = append(objs, "<< /Type /Catalog /Pages 2 0 R >>")
objs = append(objs, fmt.Sprintf("<< /Type /Pages /Kids [%s] /Count %d >>", strings.Join(kids, " "), count))
for i := range fix.Contents {
objs = append(objs, fmt.Sprintf(
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 %d 0 R >> >> /Contents %d 0 R >>",
fontID, firstContentID+i))
}
objs = append(objs, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>")
for _, content := range fix.Contents {
objs = append(objs, fmt.Sprintf("<< /Length %d >>\nstream\n%s\nendstream", len(content), content))
}
encryptID := 0
if fix.Encrypted {
encryptID = len(objs) + 1
// 形状合法的标准加密字典(V=1/R=2/40 位、O 与 U 各 32 字节),只是
// O/U 是占位字节 —— 空口令必然对不上,因此它是一份「真加密」的件。
objs = append(objs, fmt.Sprintf(
"<< /Filter /Standard /V 1 /R 2 /Length 40 /P -1 /O (%s) /U (%s) >>",
strings.Repeat("A", 32), strings.Repeat("B", 32)))
}
var buf bytes.Buffer
buf.WriteString("%PDF-1.4\n")
offsets := make([]int, 0, len(objs))
for i, body := range objs {
offsets = append(offsets, buf.Len())
fmt.Fprintf(&buf, "%d 0 obj\n%s\nendobj\n", i+1, body)
}
if fix.BreakXref && len(offsets) > 0 {
offsets[0] = 999999
}
xrefOffset := buf.Len()
fmt.Fprintf(&buf, "xref\n0 %d\n", len(objs)+1)
buf.WriteString("0000000000 65535 f \n")
for _, off := range offsets {
fmt.Fprintf(&buf, "%010d 00000 n \n", off)
}
fmt.Fprintf(&buf, "trailer\n<< /Size %d /Root 1 0 R", len(objs)+1)
if fix.Encrypted {
fmt.Fprintf(&buf, " /Encrypt %d 0 R /ID [<>]", encryptID)
}
buf.WriteString(" >>\n")
fmt.Fprintf(&buf, "startxref\n%d\n%%%%EOF\n", xrefOffset)
return buf.Bytes()
}
// TestExtract_PDFTextLayerExtracted 覆盖验收第 1 条:带文本层的**多页** PDF 能
// 抽出正文,页与页以换行分隔,页数随结果返回,种类是 pdf。
func TestExtract_PDFTextLayerExtracted(t *testing.T) {
t.Parallel()
data := buildPDF(t, pdfFixture{Contents: []string{
textContent("Page one body"),
textContent("Page two body"),
}})
res, err := Extract(pdfName, PDFMIME, data)
if err != nil {
t.Fatalf("Extract(pdf) = %v, want nil", err)
}
for _, want := range []string{"Page one body", "Page two body"} {
if !strings.Contains(res.Text, want) {
t.Fatalf("正文缺少 %q: %q", want, res.Text)
}
}
if first, second, ok := strings.Cut(res.Text, "\n"); !ok ||
first != "Page one body" || second != "Page two body" {
t.Fatalf("页序/分隔不对: %q", res.Text)
}
if res.Kind != kindPDF || res.Pages != 2 {
t.Fatalf("Kind/Pages = (%q,%d), want (%q,2)", res.Kind, res.Pages, kindPDF)
}
if res.Truncated {
t.Fatal("Truncated = true, want false")
}
if res.Chars != utf8.RuneCountInString(res.Text) {
t.Fatalf("Chars = %d, want %d", res.Chars, utf8.RuneCountInString(res.Text))
}
if res.ExtractorVersion != ExtractorVersion {
t.Fatalf("ExtractorVersion = %q, want %q", res.ExtractorVersion, ExtractorVersion)
}
}
// TestExtract_PDFJudgedByMagicNotByName 钉住「魔数唯一裁决」:同一份 PDF 字节
// 顶着 `.txt` 名、声明 `text/plain` 也照样按 PDF 抽(与存储侧的字节嗅探同源)。
func TestExtract_PDFJudgedByMagicNotByName(t *testing.T) {
t.Parallel()
data := buildPDF(t, pdfFixture{Contents: []string{textContent("magic decides")}})
res, err := Extract(txtName, "text/plain", data)
if err != nil {
t.Fatalf("Extract(pdf bytes as .txt) = %v, want nil(魔数说了算)", err)
}
if res.Kind != kindPDF || !strings.Contains(res.Text, "magic decides") {
t.Fatalf("Result = (%q,%q), want 按 PDF 抽出正文", res.Kind, res.Text)
}
}
// TestExtract_PDFWithoutTextLayerIsHonest 覆盖验收第 2 条:图片型/无文本层 PDF
// **诚实拒绝** —— ErrNoTextLayer 哨兵 + 文案说清「需要 OCR(当前未做)」,
// 且绝不返回「空正文 + nil 错误」。
func TestExtract_PDFWithoutTextLayerIsHonest(t *testing.T) {
t.Parallel()
data := buildPDF(t, pdfFixture{Contents: []string{imageContent}})
res, err := Extract(pdfName, PDFMIME, data)
if !errors.Is(err, ErrNoTextLayer) {
t.Fatalf("Extract(扫描件) = %v, want ErrNoTextLayer", err)
}
if res.Text != "" || res.Chars != 0 {
t.Fatalf("失败却回了一份正文: %+v", res)
}
msg := err.Error()
for _, want := range []string{"无文本层", "OCR", "图片型"} {
if !strings.Contains(msg, want) {
t.Fatalf("错误文案 %q 缺少 %q(必须让用户知道这是要 OCR 的图)", msg, want)
}
}
// 多页里只要有一页有文本层就算读到了(扫描件夹一页可复制文字不该整份报废)。
mixed := buildPDF(t, pdfFixture{Contents: []string{imageContent, textContent("scanned plus text")}})
if mres, merr := Extract(pdfName, PDFMIME, mixed); merr != nil || !strings.Contains(mres.Text, "scanned plus text") {
t.Fatalf("图文混排 = (%+v, %v), want 抽出那一页的文本", mres, merr)
}
}
// TestExtract_PDFDamagedIsErrorNotPanic 覆盖验收第 3 条:损坏/截断/畸形 xref
// 一律**报错且不 panic**。它走的全是真出错路径(缺 %%EOF、startxref 指不到
// xref、对象偏移指到文件之外),不是 recover 兜出来的假失败。
func TestExtract_PDFDamagedIsErrorNotPanic(t *testing.T) {
t.Parallel()
valid := buildPDF(t, pdfFixture{Contents: []string{textContent("body")}})
cases := []struct {
label string
data []byte
}{
{"只有头", []byte("%PDF-1.4\n")},
{"缺 %%EOF", []byte("%PDF-1.4\n1 0 obj\n<< >>\nendobj\n")},
{"startxref 指不到 xref", []byte("%PDF-1.4\nstartxref\n0\n%%EOF\n")},
{"截断掉一半", valid[:len(valid)/2]},
{"xref 指到文件之外", buildPDF(t, pdfFixture{Contents: []string{textContent("body")}, BreakXref: true})},
}
for _, tc := range cases {
res, err := Extract(pdfName, PDFMIME, tc.data)
if !errors.Is(err, ErrPDFDamaged) {
t.Fatalf("%s: err = %v, want ErrPDFDamaged", tc.label, err)
}
if errors.Is(err, ErrNoTextLayer) || errors.Is(err, ErrPDFEncrypted) {
t.Fatalf("%s: err = %v, 不该被归成「无文本层」或「加密」", tc.label, err)
}
if res.Text != "" || res.Chars != 0 || res.Pages != 0 {
t.Fatalf("%s: 失败却回了一份正文: %+v", tc.label, res)
}
}
}
// TestExtract_PDFZeroPagesIsDamaged 钉住边界:声明 0 页不是「没有文本层」(那是
// 扫描件),而是文件本身不成立 —— 绝不报成「需要 OCR」。
func TestExtract_PDFZeroPagesIsDamaged(t *testing.T) {
t.Parallel()
data := buildPDF(t, pdfFixture{DeclaredCount: -1})
if _, err := Extract(pdfName, PDFMIME, data); !errors.Is(err, ErrPDFDamaged) {
t.Fatalf("声明 %d 页: err = %v, want ErrPDFDamaged", -1, err)
}
}
// TestExtract_PDFEncryptedIsDistinct 钉住加密的**独立哨兵**:加密件报
// ErrPDFEncrypted(下一步动作是「解除密码」),与「损坏」可区分。
func TestExtract_PDFEncryptedIsDistinct(t *testing.T) {
t.Parallel()
data := buildPDF(t, pdfFixture{Contents: []string{textContent("secret")}, Encrypted: true})
res, err := Extract(pdfName, PDFMIME, data)
if !errors.Is(err, ErrPDFEncrypted) {
t.Fatalf("Extract(加密件) = %v, want ErrPDFEncrypted", err)
}
if errors.Is(err, ErrPDFDamaged) || errors.Is(err, ErrNoTextLayer) {
t.Fatalf("加密件被归成了别的失败: %v", err)
}
if res.Text != "" {
t.Fatalf("加密件却回了正文: %+v", res)
}
}
// TestExtract_PDFDeclaredWithoutMagicIsRejected 钉住反向:名字或声明指向 PDF、
// 字节却不是真 PDF 时,**绝不退回按纯文本抽** —— 否则「HTML 改名成 .pdf」就会以
// 文本身份进模型上下文(存储侧的同一条判定见 ContentAllowed 的用例)。
func TestExtract_PDFDeclaredWithoutMagicIsRejected(t *testing.T) {
t.Parallel()
cases := []struct {
label string
name string
mime string
data []byte
}{
{"HTML 改名成 .pdf", pdfName, "text/html", []byte("<!DOCTYPE html><html>hi</html>")},
{"纯文本改名成 .pdf", pdfName, "text/plain", []byte("这只是一段普通文本\n")},
{"声明 application/pdf 却是文本", txtName, PDFMIME, []byte("hi")},
}
for _, tc := range cases {
res, err := Extract(tc.name, tc.mime, tc.data)
if !errors.Is(err, ErrUnsupported) {
t.Fatalf("%s: err = %v, want ErrUnsupported", tc.label, err)
}
if res.Text != "" {
t.Fatalf("%s: 失败却回了正文 %q", tc.label, res.Text)
}
}
}
// TestExtract_PDFHonoursSafetyCap 覆盖验收第 5 条(安全上限侧):正文撞上
// MaxSidecarBytes 即停,并把**没抽到的页**如实标成 Truncated —— 截断必须可见。
func TestExtract_PDFHonoursSafetyCap(t *testing.T) {
t.Parallel()
// 第一页正文恰好顶到安全上限(逐页整理首尾空白后正好是这串 A),第二页有真
// 文本:上限命中后第二页必须被丢掉,且这件事必须写进 Truncated。
huge := strings.Repeat("A", MaxSidecarBytes)
data := buildPDF(t, pdfFixture{Contents: []string{textContent(huge), textContent("second page never reached")}})
res, err := Extract(pdfName, PDFMIME, data)
if err != nil {
t.Fatalf("Extract(超大正文) = %v, want nil", err)
}
if !res.Truncated {
t.Fatal("Truncated = false, want true(页被丢掉了却不标记,就是静默截断)")
}
if len(res.Text) > MaxSidecarBytes {
t.Fatalf("正文 %d 字节,超出安全上限 %d", len(res.Text), MaxSidecarBytes)
}
if strings.Contains(res.Text, "second page never reached") {
t.Fatal("正文里出现了上限之后那一页的内容(截断没有真的生效)")
}
if res.Chars != utf8.RuneCountInString(res.Text) {
t.Fatalf("Chars = %d, want %d", res.Chars, utf8.RuneCountInString(res.Text))
}
}
// TestExtract_PDFDeclaresFullPageCountWhenCapped 钉住「声明的页数」与「实际抽了几
// 页」不是一回事:撞上页数上限时 Truncated 必须为真,避免把前 N 页的正文冒充全文。
func TestExtract_PDFDeclaresFullPageCountWhenCapped(t *testing.T) {
t.Parallel()
if pages, capped := pdfPageWindow(maxPDFPages); pages != maxPDFPages || capped {
t.Fatalf("pdfPageWindow(%d) = (%d,%v), want (%d,false)", maxPDFPages, pages, capped, maxPDFPages)
}
if pages, capped := pdfPageWindow(maxPDFPages + 1); pages != maxPDFPages || !capped {
t.Fatalf("pdfPageWindow(%d) = (%d,%v), want (%d,true)", maxPDFPages+1, pages, capped, maxPDFPages)
}
if pages, capped := pdfPageWindow(1); pages != 1 || capped {
t.Fatalf("pdfPageWindow(1) = (%d,%v), want (1,false)", pages, capped)
}
}
// TestCleanPDFTextKeepsUTF8AndDropsNUL 钉住抽出的正文仍守本包的正文口径:
// 非法 UTF-8 替换、NUL 去掉,绝不因编码问题失败或把 NUL 送进上下文。
func TestCleanPDFTextKeepsUTF8AndDropsNUL(t *testing.T) {
t.Parallel()
raw := "前缀\x00中\xff间\x00后缀"
got := cleanPDFText(raw)
if !utf8.ValidString(got) {
t.Fatalf("cleanPDFText 结果不是合法 UTF-8: %q", got)
}
if strings.ContainsRune(got, 0) {
t.Fatalf("cleanPDFText 结果仍含 NUL: %q", got)
}
for _, want := range []string{"前缀", "中", "间", "后缀", utf8Replacement} {
if !strings.Contains(got, want) {
t.Fatalf("cleanPDFText(%q) = %q, 缺少 %q", raw, got, want)
}
}
}
+22 -11
View File
@@ -8,8 +8,9 @@ import (
) )
// 这些测试钉住抽取器的对外事实(design/24 §4.1/§4.3):纯文本无损、CSV/TSV 带 // 这些测试钉住抽取器的对外事实(design/24 §4.1/§4.3):纯文本无损、CSV/TSV 带
// 行数/列名摘要、超预算**诚实截断**、非法 UTF-8 不 panic 也不报废、PDF/Office // 行数/列名摘要、超预算**诚实截断**、非法 UTF-8 不 panic 也不报废、Office 与
// 与二进制**诚实拒绝**(哨兵错误,用 errors.Is 判,绝不按错误文本匹配)。 // 二进制**诚实拒绝**(哨兵错误,用 errors.Is 判,绝不按错误文本匹配)。PDF 的
// 文本层抽取另有 extract_pdf_test.go。
// csvName / tsvName 是合法的 S1 存储名形态(32 位十六进制 + 白名单扩展名), // csvName / tsvName 是合法的 S1 存储名形态(32 位十六进制 + 白名单扩展名),
// 使抽取器之外的行为(sidecar 键、路径校验)与真实上传同形。 // 使抽取器之外的行为(sidecar 键、路径校验)与真实上传同形。
@@ -17,6 +18,7 @@ const (
csvName = "0123456789abcdef0123456789abcdef.csv" csvName = "0123456789abcdef0123456789abcdef.csv"
tsvName = "0123456789abcdef0123456789abcdef.tsv" tsvName = "0123456789abcdef0123456789abcdef.tsv"
txtName = "0123456789abcdef0123456789abcdef.txt" txtName = "0123456789abcdef0123456789abcdef.txt"
pdfName = "0123456789abcdef0123456789abcdef.pdf"
) )
// TestExtract_PlainTextIsLossless 验证纯文本抽取逐字无损、统计与版本随结果返回。 // TestExtract_PlainTextIsLossless 验证纯文本抽取逐字无损、统计与版本随结果返回。
@@ -184,20 +186,29 @@ func TestExtract_RefusesUnsupportedContent(t *testing.T) {
} }
} }
// TestExtract_RefusesNoTextLayer 钉住 PDF/Office 的**诚实拒绝**(design/24 // TestExtract_RefusesNoTextLayer 钉住 Office 与「声明像是 PDF 却不是」的诚实拒绝:
// §8-1 的默认裁决):扩展名或嗅探所得 MIME 任一指向该类格式即 ErrNoTextLayer。 // Office 扩展名或嗅探所得 MIME 指向该类格式即 ErrNoTextLayer;而**名字/声明指向
// PDF、字节却不是真 PDF** 时是「说的和做的不一致」,按不支持拒绝 —— 两条都不允许
// 退回按纯文本抽(PDF 文本层本身能抽,用例见 extract_pdf_test.go)。
func TestExtract_RefusesNoTextLayer(t *testing.T) { func TestExtract_RefusesNoTextLayer(t *testing.T) {
t.Parallel() t.Parallel()
pdf := []byte("%PDF-1.7\n1 0 obj\n")
if _, err := Extract("report.pdf", "text/plain", pdf); !errors.Is(err, ErrNoTextLayer) {
t.Fatalf("Extract(.pdf) = %v, want ErrNoTextLayer", err)
}
if _, err := Extract("report.docx", "text/plain", []byte("docx")); !errors.Is(err, ErrNoTextLayer) { if _, err := Extract("report.docx", "text/plain", []byte("docx")); !errors.Is(err, ErrNoTextLayer) {
t.Fatalf("Extract(.docx) = %v, want ErrNoTextLayer", err) t.Fatalf("Extract(.docx) = %v, want ErrNoTextLayer", err)
} }
// 扩展名看着像文本、嗅探结果却是 PDF:MIME 只作否决参考,照样拒绝。 if _, err := Extract("sheet.xlsx", "text/plain", []byte("xlsx")); !errors.Is(err, ErrNoTextLayer) {
if _, err := Extract(txtName, "application/pdf", []byte("hi")); !errors.Is(err, ErrNoTextLayer) { t.Fatalf("Extract(.xlsx) = %v, want ErrNoTextLayer", err)
t.Fatalf("Extract(pdf mime) = %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)
} }
} }
+37 -14
View File
@@ -71,13 +71,20 @@ const (
// 抽取失败时注入模型的诚实原因文案(design/24 §4.4)。它们会被模型看到, // 抽取失败时注入模型的诚实原因文案(design/24 §4.4)。它们会被模型看到,
// 因此必须是「人能读懂、不含路径与内部细节」的短句;具体错误只进日志。 // 因此必须是「人能读懂、不含路径与内部细节」的短句;具体错误只进日志。
//
// PDF(切片 B1)的三种失败各有各的下一步动作,因此文案必须分开说:图片型/
// 无文本层的要说清「需要 OCR(当前未做)」,加密的要说清「解除密码后重试」,
// 损坏的就是损坏。
const ( const (
reasonNoTextLayer = "无法抽取正文:本期不支持 PDF/Office 解析(无文本层)" reasonPDFNoTextLayer = "无法抽取正文:这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)"
reasonUnsupported = "无法抽取正文:内容不在可抽取的纯文本范围内" reasonPDFEncrypted = "无法抽取正文:PDF 已加密(无法用空口令打开),请解除密码后重新上传"
reasonMissing = "无法抽取正文:文档文件不存在或已被清理" reasonPDFDamaged = "无法抽取正文:PDF 损坏或无法解析"
reasonReadFailed = "无法抽取正文:读取文档失败" reasonOfficeNoTextLayer = "无法抽取正文:本期不支持 Word/Excel/PPT 解析(无文本层)"
reasonNameInvalid = "无法抽取正文:文档引用形态非法" reasonUnsupported = "无法抽取正文:内容不在可抽取的纯文本范围内"
reasonNoUploadDir = "无法抽取正文:文档上传目录未配置" reasonMissing = "无法抽取正文:文档文件不存在或已被清理"
reasonReadFailed = "无法抽取正文:读取文档失败"
reasonNameInvalid = "无法抽取正文:文档引用形态非法"
reasonNoUploadDir = "无法抽取正文:文档上传目录未配置"
) )
// ErrInvalidName 报告一个不是 S1 存储名形态的名字被拿去做 sidecar 键或读盘 // ErrInvalidName 报告一个不是 S1 存储名形态的名字被拿去做 sidecar 键或读盘
@@ -90,8 +97,10 @@ type cachedExtract struct {
Status string Status string
// Reason 是 Status == unreadable 时注入模型的诚实原因文案。 // Reason 是 Status == unreadable 时注入模型的诚实原因文案。
Reason string Reason string
// Kind 是抽取所得内容的种类(text / csv / tsv)。 // Kind 是抽取所得内容的种类(text / csv / tsv / pdf)。
Kind string Kind string
// Pages 是 PDF 的页数(其它种类为 0)。
Pages int
// Chars 是 sidecar 正文的字符数(= 抽取所得全文的字符数,除非撞上安全上限)。 // Chars 是 sidecar 正文的字符数(= 抽取所得全文的字符数,除非撞上安全上限)。
Chars int Chars int
// Truncated 报告抽取期是否撞上了安全上限 MaxSidecarBytes(「正文到此为止」 // Truncated 报告抽取期是否撞上了安全上限 MaxSidecarBytes(「正文到此为止」
@@ -109,6 +118,7 @@ type sidecarMeta struct {
Status string `json:"status"` Status string `json:"status"`
Reason string `json:"reason,omitempty"` Reason string `json:"reason,omitempty"`
Kind string `json:"kind,omitempty"` Kind string `json:"kind,omitempty"`
Pages int `json:"pages,omitempty"`
Chars int `json:"chars,omitempty"` Chars int `json:"chars,omitempty"`
Truncated bool `json:"truncated,omitempty"` Truncated bool `json:"truncated,omitempty"`
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
@@ -175,6 +185,7 @@ func (s *sidecarStore) save(name string, rec cachedExtract, size int64) error {
Status: rec.Status, Status: rec.Status,
Reason: rec.Reason, Reason: rec.Reason,
Kind: rec.Kind, Kind: rec.Kind,
Pages: rec.Pages,
Chars: rec.Chars, Chars: rec.Chars,
Truncated: rec.Truncated, Truncated: rec.Truncated,
Name: name, Name: name,
@@ -382,13 +393,14 @@ func (r *Resolver) extract(part model.Content) cachedExtract {
if err != nil { if err != nil {
// 抽取器只报哨兵错误;日志记具体错误与名字/大小,**绝不记正文**。 // 抽取器只报哨兵错误;日志记具体错误与名字/大小,**绝不记正文**。
r.log.Info("document extraction refused", "name", part.DocumentName, "size", len(data), "error", err) r.log.Info("document extraction refused", "name", part.DocumentName, "size", len(data), "error", err)
return unreadable(reasonForExtractError(err)) return unreadable(reasonForExtractError(err, ExtOf(part.DocumentName)))
} }
r.log.Info("document extracted", "name", part.DocumentName, "size", len(data), r.log.Info("document extracted", "name", part.DocumentName, "size", len(data),
"kind", res.Kind, "chars", res.Chars, "truncated", res.Truncated) "kind", res.Kind, "pages", res.Pages, "chars", res.Chars, "truncated", res.Truncated)
return cachedExtract{ return cachedExtract{
Status: history.DocumentStatusExtracted, Status: history.DocumentStatusExtracted,
Kind: res.Kind, Kind: res.Kind,
Pages: res.Pages,
Chars: res.Chars, Chars: res.Chars,
Truncated: res.Truncated, Truncated: res.Truncated,
Text: res.Text, Text: res.Text,
@@ -427,10 +439,21 @@ func unreadable(reason string) cachedExtract {
return cachedExtract{Status: history.DocumentStatusUnreadable, Reason: reason} return cachedExtract{Status: history.DocumentStatusUnreadable, Reason: reason}
} }
// reasonForExtractError 把抽取器的哨兵错误映射为注入模型的诚实原因。 // reasonForExtractError 把抽取器的哨兵错误映射为注入模型的诚实原因。ext 是存储名
func reasonForExtractError(err error) string { // 的扩展名:同一个 `ErrNoTextLayer` 在 PDF 与 Office 上是两件不同的事(前者需要
if errors.Is(err, ErrNoTextLayer) { // OCR,后者是本期不做解析),因此必须分开说(切片 B1)。
return reasonNoTextLayer func reasonForExtractError(err error, ext string) string {
switch {
case errors.Is(err, ErrNoTextLayer):
if ext == PDFExt {
return reasonPDFNoTextLayer
}
return reasonOfficeNoTextLayer
case errors.Is(err, ErrPDFEncrypted):
return reasonPDFEncrypted
case errors.Is(err, ErrPDFDamaged):
return reasonPDFDamaged
default:
return reasonUnsupported
} }
return reasonUnsupported
} }
+5 -1
View File
@@ -29,8 +29,10 @@ type Sidecar struct {
Status string Status string
// Reason 是 Status == unreadable 时的诚实原因(正文位置为空)。 // Reason 是 Status == unreadable 时的诚实原因(正文位置为空)。
Reason string Reason string
// Kind 是抽取所得内容的种类(text / csv / tsv)。 // Kind 是抽取所得内容的种类(text / csv / tsv / pdf)。
Kind string Kind string
// Pages 是 PDF 的页数(其它种类为 0)。
Pages int
// Chars 是抽取期写盘时的正文字符数(rune)。 // Chars 是抽取期写盘时的正文字符数(rune)。
Chars int Chars int
// Truncated 报告 sidecar 正文在**抽取期**是否撞上了安全上限 // Truncated 报告 sidecar 正文在**抽取期**是否撞上了安全上限
@@ -68,6 +70,7 @@ func ReadSidecar(dataDir, name string) (Sidecar, error) {
Status: rec.Status, Status: rec.Status,
Reason: rec.Reason, Reason: rec.Reason,
Kind: rec.Kind, Kind: rec.Kind,
Pages: rec.Pages,
Chars: rec.Chars, Chars: rec.Chars,
Truncated: rec.Truncated, Truncated: rec.Truncated,
Text: rec.Text, Text: rec.Text,
@@ -123,6 +126,7 @@ func (s *sidecarStore) loadSidecar(name string) (cachedExtract, error) {
Status: meta.Status, Status: meta.Status,
Reason: meta.Reason, Reason: meta.Reason,
Kind: meta.Kind, Kind: meta.Kind,
Pages: meta.Pages,
Chars: meta.Chars, Chars: meta.Chars,
Truncated: meta.Truncated, Truncated: meta.Truncated,
Text: body, Text: body,
+52 -6
View File
@@ -129,20 +129,24 @@ func TestResolver_KeepsFullBodyAndRecordsSafetyTruncationInSidecar(t *testing.T)
} }
} }
// TestResolver_UnreadableIsHonestAndCached 钉住失败可见(design/24 §4.4):PDF // TestResolver_UnreadableIsHonestAndCached 钉住失败可见(design/24 §4.4):图片型/
// 得到 unreadable + 诚实原因,清单/回传状态有来源;失败同样只抽一次。 // 无文本层 PDF 得到 unreadable + **诚实原因**(必须点明「需要 OCR」),清单/回传
// 状态有来源;失败同样只抽一次。
func TestResolver_UnreadableIsHonestAndCached(t *testing.T) { func TestResolver_UnreadableIsHonestAndCached(t *testing.T) {
t.Parallel() t.Parallel()
layout := newTestLayout(t) layout := newTestLayout(t)
name := layout.writeUpload(t, "0123456789abcdef0123456789abcdef.pdf", []byte("%PDF-1.7\n")) name := layout.writeUpload(t, pdfName, buildPDF(t, pdfFixture{Contents: []string{imageContent}}))
r := NewResolver(Config{DataDir: layout.dataDir, UploadsDir: layout.uploads}) r := NewResolver(Config{DataDir: layout.dataDir, UploadsDir: layout.uploads})
ref, body := resolveOne(t, r, name) ref, body := resolveOne(t, r, name)
if ref.Status != history.DocumentStatusUnreadable { if ref.Status != history.DocumentStatusUnreadable {
t.Fatalf("Status = %q, want %q", ref.Status, history.DocumentStatusUnreadable) t.Fatalf("Status = %q, want %q", ref.Status, history.DocumentStatusUnreadable)
} }
if body != reasonNoTextLayer { if body != reasonPDFNoTextLayer {
t.Fatalf("body = %q, want %q", body, reasonNoTextLayer) t.Fatalf("body = %q, want %q", body, reasonPDFNoTextLayer)
}
if !strings.Contains(body, "OCR") {
t.Fatalf("原因文案 %q 没点明需要 OCR(用户要知道这份 PDF 得先转文字)", body)
} }
if _, err := os.Stat(layout.sidecarPath(name)); err != nil { if _, err := os.Stat(layout.sidecarPath(name)); err != nil {
t.Fatalf("失败的抽取也应留下 sidecar(只抽一次同样适用于失败): %v", err) t.Fatalf("失败的抽取也应留下 sidecar(只抽一次同样适用于失败): %v", err)
@@ -150,11 +154,53 @@ func TestResolver_UnreadableIsHonestAndCached(t *testing.T) {
if err := os.Remove(filepath.Join(layout.uploads, name)); err != nil { if err := os.Remove(filepath.Join(layout.uploads, name)); err != nil {
t.Fatalf("删除原文件: %v", err) t.Fatalf("删除原文件: %v", err)
} }
if _, body2 := resolveOne(t, r, name); body2 != reasonNoTextLayer { if _, body2 := resolveOne(t, r, name); body2 != reasonPDFNoTextLayer {
t.Fatalf("第二次 body = %q, want 从 sidecar 读回同一原因", body2) t.Fatalf("第二次 body = %q, want 从 sidecar 读回同一原因", body2)
} }
} }
// TestResolver_PDFPagesAndKindReachTheSidecar 钉住切片 B1 的 sidecar 元数据:
// PDF 的抽取结论带 `kind=pdf` 与**页数** —— 两者随 sidecar 落盘、可被读侧取回,
// 因此「这份 PDF 一共产出多少页」在缓存里是可查的事实(不是只留在日志里)。
func TestResolver_PDFPagesAndKindReachTheSidecar(t *testing.T) {
t.Parallel()
layout := newTestLayout(t)
name := layout.writeUpload(t, pdfName, buildPDF(t, pdfFixture{Contents: []string{
textContent("first page"),
textContent("second page"),
textContent("third page"),
}}))
r := NewResolver(Config{DataDir: layout.dataDir, UploadsDir: layout.uploads})
ref, body := resolveOne(t, r, name)
if ref.Status != history.DocumentStatusExtracted || !strings.Contains(body, "third page") {
t.Fatalf("解析 = (%+v, %q), want extracted 且含第三页正文", ref, body)
}
side, err := ReadSidecar(layout.dataDir, name)
if err != nil {
t.Fatalf("ReadSidecar: %v", err)
}
if side.Kind != kindPDF || side.Pages != 3 {
t.Fatalf("sidecar = (kind=%q, pages=%d), want (%q,3)", side.Kind, side.Pages, kindPDF)
}
raw, err := os.ReadFile(layout.sidecarPath(name))
if err != nil {
t.Fatalf("读 sidecar: %v", err)
}
header, _, ok := strings.Cut(string(raw), sidecarHeaderEnd)
if !ok {
t.Fatalf("sidecar 缺少元数据行: %q", raw)
}
var meta sidecarMeta
if err := json.Unmarshal([]byte(header), &meta); err != nil {
t.Fatalf("元数据行解码失败: %v (%q)", err, header)
}
if meta.Kind != kindPDF || meta.Pages != 3 || meta.Version != ExtractorVersion {
t.Fatalf("落盘元数据 = (kind=%q, pages=%d, v=%q), want (%q,3,%q)",
meta.Kind, meta.Pages, meta.Version, kindPDF, ExtractorVersion)
}
}
// TestResolver_MissingFileIsUnreadable 钉住「文件不存在」不是 error、也不是 // TestResolver_MissingFileIsUnreadable 钉住「文件不存在」不是 error、也不是
// 静默:它是一条 unreadable 结论 + 诚实原因(回合照常继续)。 // 静默:它是一条 unreadable 结论 + 诚实原因(回合照常继续)。
func TestResolver_MissingFileIsUnreadable(t *testing.T) { func TestResolver_MissingFileIsUnreadable(t *testing.T) {
+43 -15
View File
@@ -46,12 +46,19 @@ const (
// = 128 位)。读侧的名字形态校验与 sidecar 的键同名同宽。 // = 128 位)。读侧的名字形态校验与 sidecar 的键同名同宽。
HashHexLen = 32 HashHexLen = 32
// SniffOKMIME 是唯一放行的嗅探结果**基类型**。白名单里每一种文档(文本、 // SniffOKMIME 是**纯文本类**唯一放行的嗅探结果基类型。白名单里的每一种文本
// 代码、CSV、JSON、YAML…)经 `http.DetectContentType` 嗅探后都落在 // 文档(文本、代码、CSV、JSON、YAML…)经 `http.DetectContentType` 嗅探后都落在
// `text/plain`;HTML/XML/SVG/压缩包/可执行文件/判不定者各有自己的签名, // `text/plain`;HTML/XML/SVG/压缩包/可执行文件/判不定者各有自己的签名,
// 因此「不是 text/plain」即「拒绝」。 // 因此「不是 text/plain」即「拒绝」。PDF 是唯一的例外:它有自己的签名,放行
// 条件必须与扩展名**成对**判定(见 ContentAllowed)。
SniffOKMIME = "text/plain" SniffOKMIME = "text/plain"
// PDFExt / PDFMIME 是 PDF 的扩展名与嗅探基类型(切片 B1)。两者**成对**放行:
// `.pdf` 只收真 PDF(嗅探为 application/pdf),application/pdf 的字节也只收在
// `.pdf` 名下 —— 把 HTML 改名成 `.pdf`、或把 PDF 改名成 `.txt`,两边都过不了。
PDFExt = ".pdf"
PDFMIME = "application/pdf"
// tempPattern 是原子落盘用的临时文件前缀(点前缀,绝不与内容寻址名混淆)。 // tempPattern 是原子落盘用的临时文件前缀(点前缀,绝不与内容寻址名混淆)。
tempPattern = ".document-*" tempPattern = ".document-*"
@@ -70,8 +77,9 @@ const (
var ( var (
// ErrExtNotAllowed 报告原始文件名的扩展名不在白名单内(deny-by-default)。 // ErrExtNotAllowed 报告原始文件名的扩展名不在白名单内(deny-by-default)。
ErrExtNotAllowed = errors.New("documents: 扩展名不在白名单内") ErrExtNotAllowed = errors.New("documents: 扩展名不在白名单内")
// ErrContentNotText 报告字节嗅探否决了这份内容(基类型不是 text/plain)。 // ErrContentNotText 报告内容与扩展名对不上:字节嗅探否决了这份内容(纯文本类
ErrContentNotText = errors.New("documents: 内容不是纯文本类(字节嗅探否决)") // 必须嗅探成 `text/plain`,`.pdf` 必须嗅探成 `application/pdf`)。
ErrContentNotText = errors.New("documents: 内容与扩展名不符(字节嗅探否决)")
// ErrTooLarge 报告内容超过生效的单文件字节上限。 // ErrTooLarge 报告内容超过生效的单文件字节上限。
ErrTooLarge = errors.New("documents: 内容超过字节上限") ErrTooLarge = errors.New("documents: 内容超过字节上限")
// ErrEmpty 报告内容为空(空文件没有落盘意义,也不该产生元数据行)。 // ErrEmpty 报告内容为空(空文件没有落盘意义,也不该产生元数据行)。
@@ -195,9 +203,10 @@ func (s *Store) Limit() int {
// 拒绝(`ErrExtNotAllowed`)—— 嗅探成 `text/plain` 的 `.js`/`.py` 也在此列; // 拒绝(`ErrExtNotAllowed`)—— 嗅探成 `text/plain` 的 `.js`/`.py` 也在此列;
// 2. **空内容**拒绝(`ErrEmpty`); // 2. **空内容**拒绝(`ErrEmpty`);
// 3. **字节上限**拒绝(`ErrTooLarge`,上限见 Limit); // 3. **字节上限**拒绝(`ErrTooLarge`,上限见 Limit);
// 4. **字节嗅探唯一裁决**:`http.DetectContentType` 的基类型必须是 `text/plain`, // 4. **字节嗅探唯一裁决**:`http.DetectContentType` 的基类型必须与该扩展名
// 否则拒绝(`ErrContentNotText`)—— 客户端声明的 MIME 与文件名只作参考、 // 匹配(`ContentAllowed`):纯文本类是 `text/plain`,`.pdf` 是
// 可被否决; // `application/pdf`(HTML 改名成 `.pdf` 因此仍被拒),否则拒绝
// (`ErrContentNotText`)—— 客户端声明的 MIME 与文件名只作参考、可被否决;
// 5. 内容寻址落盘 + 路径双检 + 去重(同字节同扩展名 ⇒ 同名 ⇒ `Deduped=true`), // 5. 内容寻址落盘 + 路径双检 + 去重(同字节同扩展名 ⇒ 同名 ⇒ `Deduped=true`),
// 随后(可选)写一行 `documents` 元数据。 // 随后(可选)写一行 `documents` 元数据。
// //
@@ -232,8 +241,8 @@ func (s *Store) prepare(origName, sniffed string, data []byte) (Ref, error) {
if limit := s.Limit(); len(data) > limit { if limit := s.Limit(); len(data) > limit {
return Ref{}, fmt.Errorf("documents: save: 上限 %d 字节,本次 %d 字节: %w", limit, len(data), ErrTooLarge) return Ref{}, fmt.Errorf("documents: save: 上限 %d 字节,本次 %d 字节: %w", limit, len(data), ErrTooLarge)
} }
if !SniffAllowed(sniffed) { if !ContentAllowed(ext, sniffed) {
return Ref{}, fmt.Errorf("documents: save: 嗅探 %q 非纯文本: %w", baseMIME(sniffed), ErrContentNotText) return Ref{}, fmt.Errorf("documents: save: 扩展名 %q 与嗅探 %q 不匹配: %w", ext, baseMIME(sniffed), ErrContentNotText)
} }
sum := sha256.Sum256(data) sum := sha256.Sum256(data)
return Ref{ return Ref{
@@ -323,25 +332,44 @@ func ExtOf(filename string) string {
// `.ps1`/`.js`/`.mjs`/`.sh`/`.py`…)一律拒绝。注意 `.js`/`.py` 即便嗅探成 // `.ps1`/`.js`/`.mjs`/`.sh`/`.py`…)一律拒绝。注意 `.js`/`.py` 即便嗅探成
// `text/plain` 也必须拒绝 —— 判定以扩展名与嗅探的**合取**为准。 // `text/plain` 也必须拒绝 —— 判定以扩展名与嗅探的**合取**为准。
// //
// 这 22 项是 design/24 §9.2-2 的定稿集合,全仓只此一份(网关的名字形态校验 // 这 23 项 = design/24 §9.2-2 定稿的 22 项文本类 + `.pdf`(切片 B1 纳入),
// 与 IM 入站附件都经它判定)。 // 全仓只此一份(网关的名字形态校验与 IM 入站附件都经它判定)。`.pdf` 的放行
// **附带内容条件**:它只收真 PDF,见 ContentAllowed。
func ExtAllowed(ext string) bool { func ExtAllowed(ext string) bool {
switch ext { switch ext {
case ".txt", ".md", ".markdown", ".csv", ".tsv", ".json", ".jsonl", case ".txt", ".md", ".markdown", ".csv", ".tsv", ".json", ".jsonl",
".yaml", ".yml", ".log", ".ini", ".cfg", ".toml", ".yaml", ".yml", ".log", ".ini", ".cfg", ".toml",
".go", ".ts", ".tsx", ".java", ".c", ".h", ".cpp", ".rs", ".sql": ".go", ".ts", ".tsx", ".java", ".c", ".h", ".cpp", ".rs", ".sql",
PDFExt:
return true return true
default: default:
return false return false
} }
} }
// SniffAllowed 报告嗅探所得类型是否落在放行的文本类内。它是「类型裁决的唯一 // SniffAllowed 报告嗅探所得类型是否落在放行的**纯文本类**内。它是「类型裁决的
// 裁决者」在代码里的落点:客户端声明的 `Content-Type` 从不参与决定。 // 唯一裁决者」在代码里的落点:客户端声明的 `Content-Type` 从不参与决定。
//
// 它只管纯文本那一半;完整的成对裁决是 ContentAllowed(写侧与读侧都走它)。
func SniffAllowed(sniffed string) bool { func SniffAllowed(sniffed string) bool {
return baseMIME(sniffed) == SniffOKMIME return baseMIME(sniffed) == SniffOKMIME
} }
// ContentAllowed 报告 (扩展名, 嗅探基类型) 这一对是否被放行 —— 它是**类型裁决
// 的唯一实现**,写侧(Store.Save)与读侧(网关下发 MIME)都调它,两边因此不可能
// 漂移。判定只认这一对,客户端声明的 `Content-Type` 与原始文件名只作参考。
//
// 规则是**成对**的,因为放行集合里有一类格式(PDF)的签名与纯文本不同:
// - 纯文本类(.txt/.md/.csv/…):嗅探基类型必须恰为 `text/plain`;
// - PDF(`PDFExt`):嗅探基类型必须恰为 `PDFMIME` —— **反向也成立**:PDF 字节
// 顶着 `.txt` 上传同样被拒(内容和扩展名必须互相印证)。
func ContentAllowed(ext, sniffed string) bool {
if ext == PDFExt {
return baseMIME(sniffed) == PDFMIME
}
return SniffAllowed(sniffed)
}
// hashName 返回内容寻址的落盘名:sha256 十六进制的前 HashHexLen 位 + ext。 // hashName 返回内容寻址的落盘名:sha256 十六进制的前 HashHexLen 位 + ext。
// 同一份字节配同一个扩展名必然得到同一个名字,去重因此是「同名」的自然结果。 // 同一份字节配同一个扩展名必然得到同一个名字,去重因此是「同名」的自然结果。
func hashName(sum []byte, ext string) string { func hashName(sum []byte, ext string) string {
+60 -10
View File
@@ -21,19 +21,31 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/db" "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{ var whitelistExtensions = []string{
".txt", ".md", ".markdown", ".csv", ".tsv", ".json", ".jsonl", ".txt", ".md", ".markdown", ".csv", ".tsv", ".json", ".jsonl",
".yaml", ".yml", ".log", ".ini", ".cfg", ".toml", ".yaml", ".yml", ".log", ".ini", ".cfg", ".toml",
".go", ".ts", ".tsx", ".java", ".c", ".h", ".cpp", ".rs", ".sql", ".go", ".ts", ".tsx", ".java", ".c", ".h", ".cpp", ".rs", ".sql",
PDFExt,
} }
// deniedExtensions 是必须被拒的扩展名(含「嗅探成 text/plain 也不放行」的脚本类)。 // deniedExtensions 是必须被拒的扩展名(含「嗅探成 text/plain 也不放行」的脚本类)。
var deniedExtensions = []string{ var deniedExtensions = []string{
".html", ".svg", ".xml", ".zip", ".docx", ".xlsx", ".exe", ".dll", ".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(默认上限)。 // newTestStore 构造一个落在临时目录的 Store(默认上限)。
@@ -53,12 +65,12 @@ func expectedName(t *testing.T, ext string, data []byte) (string, string) {
return hexSum[:HashHexLen] + ext, hexSum return hexSum[:HashHexLen] + ext, hexSum
} }
// TestStoreSaveAcceptsWhitelistedExtensions 覆盖验收第 1 条:22 项白名单逐项放行, // TestStoreSaveAcceptsWhitelistedExtensions 覆盖验收第 1 条:23 项白名单逐项放行,
// 落盘名是内容寻址名、MIME 是嗅探基类型、内容逐字节一致。 // 落盘名是内容寻址名、MIME 是嗅探基类型、内容逐字节一致。
func TestStoreSaveAcceptsWhitelistedExtensions(t *testing.T) { func TestStoreSaveAcceptsWhitelistedExtensions(t *testing.T) {
t.Parallel() t.Parallel()
if len(whitelistExtensions) != 22 { if len(whitelistExtensions) != 23 {
t.Fatalf("白名单项数 = %d, want 22(design/24 §9.2-2 定稿)", len(whitelistExtensions)) t.Fatalf("白名单项数 = %d, want 23(design/24 §9.2-2 的 22 项文本类 + .pdf)", len(whitelistExtensions))
} }
store := newTestStore(t, StoreConfig{}) store := newTestStore(t, StoreConfig{})
ctx := context.Background() ctx := context.Background()
@@ -66,7 +78,7 @@ func TestStoreSaveAcceptsWhitelistedExtensions(t *testing.T) {
if !ExtAllowed(ext) { if !ExtAllowed(ext) {
t.Fatalf("ExtAllowed(%q) = false, want true(白名单缺项或拼写变了)", ext) t.Fatalf("ExtAllowed(%q) = false, want true(白名单缺项或拼写变了)", ext)
} }
data := []byte("昔涟的测试文本 " + ext + "\n") data := whitelistSample(t, ext)
wantName, wantSHA := expectedName(t, ext, data) wantName, wantSHA := expectedName(t, ext, data)
ref, err := store.Save(ctx, "orig"+ext, data) ref, err := store.Save(ctx, "orig"+ext, data)
if err != nil { if err != nil {
@@ -75,9 +87,13 @@ func TestStoreSaveAcceptsWhitelistedExtensions(t *testing.T) {
if ref.Name != wantName || ref.SHA256 != wantSHA { if ref.Name != wantName || ref.SHA256 != wantSHA {
t.Fatalf("%s: name/sha = (%q,%q), want (%q,%q)", ext, ref.Name, ref.SHA256, wantName, 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)", 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) stored, err := os.ReadFile(ref.Path)
if err != nil { 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, // TestStoreSaveRejectsDisallowedExtensions 覆盖验收第 1 条的拒绝侧:deny-by-default,
// 含 `.js`/`.py`/`.sh` 这类「内容确是纯文本但扩展名不在白名单」的样本。 // 含 `.js`/`.py`/`.sh` 这类「内容确是纯文本但扩展名不在白名单」的样本。
func TestStoreSaveRejectsDisallowedExtensions(t *testing.T) { func TestStoreSaveRejectsDisallowedExtensions(t *testing.T) {
+1 -1
View File
@@ -102,7 +102,7 @@ func (s *Server) resolveChatDocument(ref chatDocumentRef) (model.Document, error
} }
return model.Document{ return model.Document{
Name: ref.Name, Name: ref.Name,
MIME: documentReadMIME(f), MIME: documentReadMIME(f, ref.Name),
URL: documentFilePrefix + ref.Name, URL: documentFilePrefix + ref.Name,
Size: info.Size(), Size: info.Size(),
}, nil }, nil
+21 -13
View File
@@ -72,10 +72,12 @@ const (
// documentsLogSource 是文档子系统的日志来源标签。 // documentsLogSource 是文档子系统的日志来源标签。
documentsLogSource = "gateway/documents" documentsLogSource = "gateway/documents"
// documentSniffOKMIME 是唯一放行的嗅探结果基类型。白名单里的每一种文档 // documentSniffOKMIME 是**纯文本类**放行的嗅探结果基类型。白名单里的文本类
// (文本、代码、CSV、JSON、YAML…)经 `http.DetectContentType` 嗅探后都落在 // 文档(文本、代码、CSV、JSON、YAML…)经 `http.DetectContentType` 嗅探后都落在
// `text/plain`;HTML/XML/SVG/压缩包/可执行文件/判不定者各有自己的签名, // `text/plain`;HTML/XML/SVG/压缩包/可执行文件/判不定者各有自己的签名,
// 因此「不是 text/plain」即「拒绝」。取值取自共享存储核心(同一裁决)。 // 因此「不是 text/plain」即「拒绝」。取值取自共享存储核心(同一裁决)。
// PDF(切片 B1)是唯一的例外:它有 `application/pdf` 这个自己的签名,放行
// 条件必须与 `.pdf` 扩展名**成对**判定(`documents.ContentAllowed`)。
documentSniffOKMIME = documents.SniffOKMIME documentSniffOKMIME = documents.SniffOKMIME
// documentFallbackMIME 是读侧嗅探不出放行类型时下发的媒体类型。配合 // documentFallbackMIME 是读侧嗅探不出放行类型时下发的媒体类型。配合
@@ -126,7 +128,8 @@ func documentExtOf(filename string) string {
// 一切(`.html`/`.svg`/`.xml`/`.zip`/`.docx`/`.xlsx`/`.exe`/`.dll`/`.bat`/ // 一切(`.html`/`.svg`/`.xml`/`.zip`/`.docx`/`.xlsx`/`.exe`/`.dll`/`.bat`/
// `.cmd`/`.ps1`/`.js`/`.mjs`/`.sh`/`.py`…)一律拒绝。注意 `.js`/`.py` 即便 // `.cmd`/`.ps1`/`.js`/`.mjs`/`.sh`/`.py`…)一律拒绝。注意 `.js`/`.py` 即便
// 嗅探成 `text/plain` 也必须拒绝 —— 判定以扩展名与嗅探的**合取**为准。 // 嗅探成 `text/plain` 也必须拒绝 —— 判定以扩展名与嗅探的**合取**为准。
// 白名单本体只存在于 `documents.ExtAllowed`(22 项,design/24 §9.2-2 定稿)。 // 白名单本体只存在于 `documents.ExtAllowed`(23 项 = design/24 §9.2-2 的 22 项
// 文本类 + `.pdf`;切片 B1 纳入 PDF)。
func documentExtAllowed(ext string) bool { func documentExtAllowed(ext string) bool {
return documents.ExtAllowed(ext) return documents.ExtAllowed(ext)
} }
@@ -141,11 +144,12 @@ func documentBaseMIME(sniffed string) string {
return strings.ToLower(strings.TrimSpace(strings.Split(sniffed, ";")[0])) return strings.ToLower(strings.TrimSpace(strings.Split(sniffed, ";")[0]))
} }
// documentSniffAllowed 报告嗅探所得类型是否落在放行的文本类内。它是「类型裁决 // documentSniffAllowed 报告 (扩展名, 嗅探结果) 这一对是否被放行。它是「类型裁决
// 的唯一裁决者」在代码里的落点:客户端声明的 `Content-Type` 从不参与决定。 // 的唯一裁决者」在代码里的落点:客户端声明的 `Content-Type` 从不参与决定。
// 裁决本体只存在于 `documents.SniffAllowed`(写侧与读侧同一份)。 // 裁决本体只存在于 `documents.ContentAllowed`(写侧与读侧同一份)—— 成对判定,
func documentSniffAllowed(sniffed string) bool { // 因为 `.pdf` 的放行条件是 `application/pdf` 而不是 `text/plain`(切片 B1)。
return documents.SniffAllowed(sniffed) func documentSniffAllowed(ext, sniffed string) bool {
return documents.ContentAllowed(ext, sniffed)
} }
// validDocumentName 报告 name 是否恰好是 32 位小写十六进制主名加白名单文档 // validDocumentName 报告 name 是否恰好是 32 位小写十六进制主名加白名单文档
@@ -356,7 +360,7 @@ func (s *Server) handleDocumentFile(w http.ResponseWriter, r *http.Request) {
writeError(w, documentNotFoundError()) writeError(w, documentNotFoundError())
return return
} }
w.Header().Set("Content-Type", documentReadMIME(f)) w.Header().Set("Content-Type", documentReadMIME(f, name))
w.Header().Set("Content-Disposition", "attachment; filename=\""+name+"\"") w.Header().Set("Content-Disposition", "attachment; filename=\""+name+"\"")
w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Content-Type-Options", "nosniff")
// 内容寻址 ⇒ 名字变则内容变,可以放心 immutable。 // 内容寻址 ⇒ 名字变则内容变,可以放心 immutable。
@@ -365,8 +369,11 @@ func (s *Server) handleDocumentFile(w http.ResponseWriter, r *http.Request) {
} }
// documentReadMIME 嗅探已存文档的头部并给出可安全下发的媒体类型。它不读数据库: // documentReadMIME 嗅探已存文档的头部并给出可安全下发的媒体类型。它不读数据库:
// 落盘内容就是唯一真源,嗅探不出放行类型时一律 application/octet-stream。 // 落盘内容就是唯一真源;`name` 只用来取扩展名 —— 类型裁决是**成对**的
func documentReadMIME(f *os.File) string { // (`documents.ContentAllowed`),因此真 PDF 会如实下发 `application/pdf`
// (配合 `attachment` + `nosniff`,浏览器仍不会内联渲染),判不定者与原响应一致
// 地退化为 `application/octet-stream`。
func documentReadMIME(f *os.File, name string) string {
buf := make([]byte, uploadSniffBytes) buf := make([]byte, uploadSniffBytes)
n, err := io.ReadFull(f, buf) n, err := io.ReadFull(f, buf)
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) { if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
@@ -376,7 +383,7 @@ func documentReadMIME(f *os.File) string {
return documentFallbackMIME return documentFallbackMIME
} }
sniffed := http.DetectContentType(buf[:n]) sniffed := http.DetectContentType(buf[:n])
if !documentSniffAllowed(sniffed) { if !documentSniffAllowed(documents.ExtOf(name), sniffed) {
return documentFallbackMIME return documentFallbackMIME
} }
return documentBaseMIME(sniffed) return documentBaseMIME(sniffed)
@@ -398,10 +405,11 @@ func documentTooLargeError(limit int) error {
} }
// documentUnsupportedError 构造类型拒绝错误:415 + unsupported_media_type。 // documentUnsupportedError 构造类型拒绝错误:415 + unsupported_media_type。
// 文案刻意同时点明「扩展名」与「内容」两条口径,因为任一不过都走这里。 // 文案刻意同时点明「扩展名」与「内容」两条口径,因为任一不过都走这里;
// PDF 是唯一「扩展名与内容必须互相对应」的放行格式,因此文案里点出它。
func documentUnsupportedError() error { func documentUnsupportedError() error {
return apperr.Wrap(apperr.ErrUnsupportedMediaType, documentUnsupportedCode, return apperr.Wrap(apperr.ErrUnsupportedMediaType, documentUnsupportedCode,
"只接受白名单内的纯文本类文档(如 .txt/.md/.csv/.json),且内容必须确为文本") "只接受白名单内的文档(如 .txt/.md/.csv/.json,或内容确为 PDF 的 .pdf),且内容必须与扩展名相符")
} }
// documentNotFoundError 构造文档缺失错误:404 + document_not_found。名字形态 // documentNotFoundError 构造文档缺失错误:404 + document_not_found。名字形态
@@ -0,0 +1,104 @@
package gateway
// 本文件锁定切片 B1 对**文档端点**的那一半改动:`.pdf` 进白名单,而类型判定是
// (扩展名, 字节嗅探) 的**成对**合取 —— 真 PDF 能上传、能被读端点按 PDF 下发;
// 而 HTML/纯文本改名成 `.pdf`、PDF 改名成 `.txt` 一律 415。
//
// 端点与错误码的其余口径(去重、鉴权、路径穿越、限额、元数据)在 documents_test.go,
// 本文件只补 `.pdf` 这一条通路,避免把那边的用例文件撑过 500 行。
import (
"bytes"
"fmt"
"net/http"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
)
// minimalPDF 拼一份**合法的最简 PDF**(单页 + 一行文本),供 `.pdf` 白名单用例
// 使用。它与 `internal/runtime/documents` 的测试夹具同构(那边有完整版,覆盖多页/
// 无文本层/加密/断 xref);这里只需要「字节嗅探认得出 application/pdf」这一点。
func minimalPDF(text string) []byte {
content := "BT /F1 12 Tf 72 720 Td (" + text + ") Tj ET"
objs := []string{
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>",
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
fmt.Sprintf("<< /Length %d >>\nstream\n%s\nendstream", len(content), content),
}
var buf bytes.Buffer
buf.WriteString("%PDF-1.4\n")
offsets := make([]int, 0, len(objs))
for i, body := range objs {
offsets = append(offsets, buf.Len())
fmt.Fprintf(&buf, "%d 0 obj\n%s\nendobj\n", i+1, body)
}
xref := buf.Len()
fmt.Fprintf(&buf, "xref\n0 %d\n0000000000 65535 f \n", len(objs)+1)
for _, off := range offsets {
fmt.Fprintf(&buf, "%010d 00000 n \n", off)
}
fmt.Fprintf(&buf, "trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(objs)+1, xref)
return buf.Bytes()
}
// TestDocumentUploadAcceptsRealPDF 覆盖验收第 4 条(切片 B1,**存储端行为变更**):
// `.pdf` 进白名单后**真 PDF** 能上传(201,mime = `application/pdf`,落盘名以
// `.pdf` 结尾)并被读端点按 `application/pdf` 下发(仍 attachment + nosniff,
// 浏览器不可内联);而 **HTML/纯文本改名成 `.pdf`、PDF 改名成 `.txt`** 一律 415
// —— 判定是 (扩展名, 字节嗅探) 的**成对**合取,改扩展名换不了类型。
func TestDocumentUploadAcceptsRealPDF(t *testing.T) {
t.Parallel()
env := newDocumentsEnv(t, 0)
tok := env.token(t, auth.RoleOwner)
pdfBytes := minimalPDF("hello pdf")
resp, body := postDocument(t, env, "report.pdf", pdfBytes, "text/plain", tok)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("真 PDF 上传: status = %d, want 201 (body=%s)", resp.StatusCode, body)
}
got := decodeDocumentEnvelope(t, body)
wantName, wantSHA := docHashNameOf(t, "report.pdf", pdfBytes)
if got.Data.Name != wantName || got.Data.MIME != documents.PDFMIME || got.Data.SHA256 != wantSHA {
t.Fatalf("真 PDF 上传: (name,mime,sha) = (%q,%q,%q), want (%q,%q,%q)",
got.Data.Name, got.Data.MIME, got.Data.SHA256, wantName, documents.PDFMIME, wantSHA)
}
rresp, rbody := getDocument(t, env, documentFilePrefix+got.Data.Name, tok)
if rresp.StatusCode != http.StatusOK {
t.Fatalf("读真 PDF: status = %d, want 200", rresp.StatusCode)
}
if ct := rresp.Header.Get("Content-Type"); ct != documents.PDFMIME {
t.Fatalf("读真 PDF: Content-Type = %q, want %q(成对裁决在读侧同一份)", ct, documents.PDFMIME)
}
if d := rresp.Header.Get("Content-Disposition"); !strings.HasPrefix(d, "attachment") ||
rresp.Header.Get("X-Content-Type-Options") != "nosniff" {
t.Fatalf("读真 PDF: 响应头 = (%q,%q), want attachment 前缀 + nosniff(永不可内联)",
d, rresp.Header.Get("X-Content-Type-Options"))
}
if !bytes.Equal(rbody, pdfBytes) {
t.Fatal("读真 PDF: 下发字节与上传内容不一致")
}
for _, tc := range []struct {
label string
filename string
content []byte
}{
{"HTML 改名成 .pdf", "lie.pdf", []byte("<!DOCTYPE html><html><body>hi</body></html>")},
{"纯文本改名成 .pdf", "plain.pdf", []byte("这只是一段普通文本\n")},
{"PDF 改名成 .txt", "lie.txt", pdfBytes},
} {
resp, body := postDocument(t, env, tc.filename, tc.content, "text/plain", tok)
if resp.StatusCode != http.StatusUnsupportedMediaType {
t.Fatalf("%s: status = %d, want 415 (body=%s)", tc.label, resp.StatusCode, body)
}
if code := decodeDocumentEnvelope(t, body).Error.Code; code != documentUnsupportedCode {
t.Fatalf("%s: error.code = %q, want %q", tc.label, code, documentUnsupportedCode)
}
}
}