diff --git a/go.mod b/go.mod index 5de73ac..9bbab1f 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/google/jsonschema-go v0.4.3 github.com/gorilla/websocket v1.5.3 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/robfig/cron/v3 v3.0.1 github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 diff --git a/go.sum b/go.sum index 93eac46..0b79250 100644 --- a/go.sum +++ b/go.sum @@ -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/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 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/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/modelcontextprotocol/go-sdk v1.8.0 h1:KIvahhYqwtbeniWVPs3TcXEA7b8jEtwfBpOTAI+Urx4= diff --git a/internal/runtime/documents/extract.go b/internal/runtime/documents/extract.go index 4ca534e..1a8aa40 100644 --- a/internal/runtime/documents/extract.go +++ b/internal/runtime/documents/extract.go @@ -1,10 +1,12 @@ -// Package documents 实现文档摄取层(design/24 §4):把一份已落盘的文本类文档 +// Package documents 实现文档摄取层(design/24 §4):把一份已落盘的文档 // 抽成可注入模型可见上下文的正文,并把结果缓存进 sidecar(只抽一次)。 // -// 本期只处理纯文本类(design/24 §1.2 的 P0):抽取是**纯函数**、零新依赖、 -// 不触网、不落盘。PDF/Office 按 §8-1 的默认裁决**诚实拒绝**(返回 -// ErrNoTextLayer):绝不假装成功,也绝不返回「空正文 + nil 错误」——后者会让 -// 上游把「读不到」误当成「读到了空文件」,正是 F2 要消灭的那种误解。 +// 本期处理**纯文本类**(design/24 §1.2 的 P0)与 **PDF 的文本层**(切片 B1, +// design/24 §7 的 S6 第一段):抽取是**纯函数**、不触网、不落盘(sidecar 由 +// 装配期写)。Office(.doc/.docx/.xls/.xlsx/.ppt/.pptx/.odt/.ods/.odp/.rtf)与 +// **图片型/扫描件 PDF** 按 §8-1 的裁决**诚实拒绝**(返回 `ErrNoTextLayer`, +// 文案说清「需要 OCR」):绝不假装成功,也绝不返回「空正文 + nil 错误」——后者 +// 会让上游把「读不到」误当成「读到了空文件」,正是 F2 要消灭的那种误解。 package documents import ( @@ -26,7 +28,11 @@ const ( // // v2(切片 T1 §7.2):抽取期**不再**按注入预算截断,只在安全上限 // (MaxSidecarBytes)处截断 —— 旧缓存里的正文可能少了一大截,必须重抽。 - ExtractorVersion = "documents/v2" + // + // v3(切片 B1):**PDF 文本层开始能抽**(原先一律诚实拒绝),且失败原因的 + // 分类与文案全变了(无文本层/加密/损坏各自成哨兵)—— 旧缓存里的 unreadable + // 结论已经是错的,必须重抽。 + ExtractorVersion = "documents/v3" // MaxSidecarBytes 是 sidecar 正文的**安全上限**(字节,8 MiB 文本)。 // @@ -49,10 +55,11 @@ const ( // 目的是挡住被误存进来的二进制,不是做通用格式识别。 binaryProbeBytes = 512 - // kindText / kindCSV / kindTSV 是 Result.Kind 的取值。 + // kindText / kindCSV / kindTSV / kindPDF 是 Result.Kind 的取值。 kindText = "text" kindCSV = "csv" kindTSV = "tsv" + kindPDF = "pdf" // csvDelim / tsvDelim 是摘要切列名用的分隔符。 csvDelim = "," @@ -75,9 +82,11 @@ var ( // ErrUnsupported 报告内容不在可抽取的纯文本范围内(二进制、非文本类型)。 ErrUnsupported = errors.New("documents: 内容不在可抽取的纯文本范围内") - // ErrNoTextLayer 报告这是一份需要文本层解析的格式(PDF/Office):本期 - // 按 design/24 §8-1 诚实拒绝,不引解析库(单二进制体积与失败面)。 - ErrNoTextLayer = errors.New("documents: 无文本层(PDF/Office 本期不支持解析)") + // ErrNoTextLayer 报告这份内容的格式需要文本层,而我们读不出文本层:一类是 + // **图片型/扫描件 PDF**(它本就没有文本层,要先栅格化再 OCR,纯 Go 没有可靠 + // 的栅格化实现 —— 切片 B1 的口径),另一类是本期不解析的 **Office** + // (.doc/.docx/.xls/.xlsx/.ppt/.pptx/.odt/.ods/.odp/.rtf)。 + ErrNoTextLayer = errors.New("documents: 无文本层(图片型/扫描件 PDF 需 OCR;Office 本期不支持解析)") ) // Result 是一次抽取的产物。它是抽取器的全部对外事实:正文、字符数、是否 @@ -92,8 +101,11 @@ type Result struct { // 它与「注入被截断」是两件事:后者由装配期按注入预算判定并写进 // history.DocumentRef(切片 T1 §7.2)。 Truncated bool - // Kind 是抽取出内容的种类(text / csv / tsv)。 + // Kind 是抽取出内容的种类(text / csv / tsv / pdf)。 Kind string + // Pages 是 PDF 的页数(其它种类恒为 0)。它随 sidecar 元数据落盘,用来让 + // 「这份 PDF 有多大」这件事在缓存里可见(切片 B1)。 + Pages int // ExtractorVersion 是产出本结果的抽取器版本。 ExtractorVersion string } @@ -101,20 +113,36 @@ type Result struct { // Extract 把一份文档的字节抽成可注入的正文。它是**纯函数**:不读盘、不写盘、 // 不依赖任何外部状态,因此 sidecar 命中与未命中得到逐字相同的结果。 // -// name 是服务端存储名(扩展名决定 CSV/TSV 特例与办公格式的诚实拒绝),mime 是 +// name 是服务端存储名(扩展名决定 CSV/TSV 特例与 Office 的诚实拒绝),mime 是 // 服务端字节嗅探所得:按 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 错误」。 func Extract(name, mime string, data []byte) (Result, error) { 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) { return Result{}, fmt.Errorf("documents: extract %s: %w", ext, ErrNoTextLayer) } - if base := baseMIME(mime); base == pdfMIME { - return Result{}, fmt.Errorf("documents: extract mime %q: %w", base, ErrNoTextLayer) - } else if base != "" && !textualMIME(base) { + if base != "" && !textualMIME(base) { return Result{}, fmt.Errorf("documents: extract mime %q: %w", base, ErrUnsupported) } if looksBinary(data) { @@ -137,12 +165,13 @@ func Extract(name, mime string, data []byte) (Result, error) { return res, nil } -// noTextLayerExt 报告扩展名是否属于**诚实拒绝**的办公/文档格式(design/24 -// §8-1 的默认裁决):它们的正文在压缩流或文本层里,本期不引解析库,因此明确 -// 报告读不到,而不是把二进制塞进上下文。 +// noTextLayerExt 报告扩展名是否属于**读不到文本层**的格式(design/24 §8-1 的 +// 默认裁决):**Office 一族**的正文在压缩流或文本层里,本期不引解析库,因此明确 +// 报告读不到,而不是把二进制塞进上下文。PDF **不在**这里 —— 它的文本层由 +// `extract_pdf.go` 真抽(抽不到文本层的图片型 PDF 由那条路径给出 ErrNoTextLayer)。 func noTextLayerExt(ext string) bool { switch ext { - case ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", + case ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".odt", ".ods", ".odp", ".rtf": return true default: @@ -150,9 +179,6 @@ func noTextLayerExt(ext string) bool { } } -// pdfMIME 是拒绝对应的嗅探基类型。 -const pdfMIME = "application/pdf" - // baseMIME 把一个媒体类型归一为基类型(去参数、小写)。S1 的存储早已只落 // 基类型,这里只是不让带参数的输入影响判定。 func baseMIME(mt string) string { diff --git a/internal/runtime/documents/extract_pdf.go b/internal/runtime/documents/extract_pdf.go new file mode 100644 index 0000000..74eb3a2 --- /dev/null +++ b/internal/runtime/documents/extract_pdf.go @@ -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)) +} diff --git a/internal/runtime/documents/extract_pdf_test.go b/internal/runtime/documents/extract_pdf_test.go new file mode 100644 index 0000000..bde6205 --- /dev/null +++ b/internal/runtime/documents/extract_pdf_test.go @@ -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("hi")}, + {"纯文本改名成 .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) + } + } +} diff --git a/internal/runtime/documents/extract_test.go b/internal/runtime/documents/extract_test.go index 848bbbd..b8fc035 100644 --- a/internal/runtime/documents/extract_test.go +++ b/internal/runtime/documents/extract_test.go @@ -8,8 +8,9 @@ import ( ) // 这些测试钉住抽取器的对外事实(design/24 §4.1/§4.3):纯文本无损、CSV/TSV 带 -// 行数/列名摘要、超预算**诚实截断**、非法 UTF-8 不 panic 也不报废、PDF/Office -// 与二进制**诚实拒绝**(哨兵错误,用 errors.Is 判,绝不按错误文本匹配)。 +// 行数/列名摘要、超预算**诚实截断**、非法 UTF-8 不 panic 也不报废、Office 与 +// 二进制**诚实拒绝**(哨兵错误,用 errors.Is 判,绝不按错误文本匹配)。PDF 的 +// 文本层抽取另有 extract_pdf_test.go。 // csvName / tsvName 是合法的 S1 存储名形态(32 位十六进制 + 白名单扩展名), // 使抽取器之外的行为(sidecar 键、路径校验)与真实上传同形。 @@ -17,6 +18,7 @@ const ( csvName = "0123456789abcdef0123456789abcdef.csv" tsvName = "0123456789abcdef0123456789abcdef.tsv" txtName = "0123456789abcdef0123456789abcdef.txt" + pdfName = "0123456789abcdef0123456789abcdef.pdf" ) // TestExtract_PlainTextIsLossless 验证纯文本抽取逐字无损、统计与版本随结果返回。 @@ -184,20 +186,29 @@ func TestExtract_RefusesUnsupportedContent(t *testing.T) { } } -// TestExtract_RefusesNoTextLayer 钉住 PDF/Office 的**诚实拒绝**(design/24 -// §8-1 的默认裁决):扩展名或嗅探所得 MIME 任一指向该类格式即 ErrNoTextLayer。 +// TestExtract_RefusesNoTextLayer 钉住 Office 与「声明像是 PDF 却不是」的诚实拒绝: +// Office 扩展名或嗅探所得 MIME 指向该类格式即 ErrNoTextLayer;而**名字/声明指向 +// PDF、字节却不是真 PDF** 时是「说的和做的不一致」,按不支持拒绝 —— 两条都不允许 +// 退回按纯文本抽(PDF 文本层本身能抽,用例见 extract_pdf_test.go)。 func TestExtract_RefusesNoTextLayer(t *testing.T) { 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) { t.Fatalf("Extract(.docx) = %v, want ErrNoTextLayer", err) } - // 扩展名看着像文本、嗅探结果却是 PDF:MIME 只作否决参考,照样拒绝。 - if _, err := Extract(txtName, "application/pdf", []byte("hi")); !errors.Is(err, ErrNoTextLayer) { - t.Fatalf("Extract(pdf mime) = %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) } } diff --git a/internal/runtime/documents/sidecar.go b/internal/runtime/documents/sidecar.go index 788c06b..8cf4f8f 100644 --- a/internal/runtime/documents/sidecar.go +++ b/internal/runtime/documents/sidecar.go @@ -71,13 +71,20 @@ const ( // 抽取失败时注入模型的诚实原因文案(design/24 §4.4)。它们会被模型看到, // 因此必须是「人能读懂、不含路径与内部细节」的短句;具体错误只进日志。 +// +// PDF(切片 B1)的三种失败各有各的下一步动作,因此文案必须分开说:图片型/ +// 无文本层的要说清「需要 OCR(当前未做)」,加密的要说清「解除密码后重试」, +// 损坏的就是损坏。 const ( - reasonNoTextLayer = "无法抽取正文:本期不支持 PDF/Office 解析(无文本层)" - reasonUnsupported = "无法抽取正文:内容不在可抽取的纯文本范围内" - reasonMissing = "无法抽取正文:文档文件不存在或已被清理" - reasonReadFailed = "无法抽取正文:读取文档失败" - reasonNameInvalid = "无法抽取正文:文档引用形态非法" - reasonNoUploadDir = "无法抽取正文:文档上传目录未配置" + reasonPDFNoTextLayer = "无法抽取正文:这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)" + reasonPDFEncrypted = "无法抽取正文:PDF 已加密(无法用空口令打开),请解除密码后重新上传" + reasonPDFDamaged = "无法抽取正文:PDF 损坏或无法解析" + reasonOfficeNoTextLayer = "无法抽取正文:本期不支持 Word/Excel/PPT 解析(无文本层)" + reasonUnsupported = "无法抽取正文:内容不在可抽取的纯文本范围内" + reasonMissing = "无法抽取正文:文档文件不存在或已被清理" + reasonReadFailed = "无法抽取正文:读取文档失败" + reasonNameInvalid = "无法抽取正文:文档引用形态非法" + reasonNoUploadDir = "无法抽取正文:文档上传目录未配置" ) // ErrInvalidName 报告一个不是 S1 存储名形态的名字被拿去做 sidecar 键或读盘 @@ -90,8 +97,10 @@ type cachedExtract struct { Status string // Reason 是 Status == unreadable 时注入模型的诚实原因文案。 Reason string - // Kind 是抽取所得内容的种类(text / csv / tsv)。 + // Kind 是抽取所得内容的种类(text / csv / tsv / pdf)。 Kind string + // Pages 是 PDF 的页数(其它种类为 0)。 + Pages int // Chars 是 sidecar 正文的字符数(= 抽取所得全文的字符数,除非撞上安全上限)。 Chars int // Truncated 报告抽取期是否撞上了安全上限 MaxSidecarBytes(「正文到此为止」 @@ -109,6 +118,7 @@ type sidecarMeta struct { Status string `json:"status"` Reason string `json:"reason,omitempty"` Kind string `json:"kind,omitempty"` + Pages int `json:"pages,omitempty"` Chars int `json:"chars,omitempty"` Truncated bool `json:"truncated,omitempty"` Name string `json:"name,omitempty"` @@ -175,6 +185,7 @@ func (s *sidecarStore) save(name string, rec cachedExtract, size int64) error { Status: rec.Status, Reason: rec.Reason, Kind: rec.Kind, + Pages: rec.Pages, Chars: rec.Chars, Truncated: rec.Truncated, Name: name, @@ -382,13 +393,14 @@ func (r *Resolver) extract(part model.Content) cachedExtract { if err != nil { // 抽取器只报哨兵错误;日志记具体错误与名字/大小,**绝不记正文**。 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), - "kind", res.Kind, "chars", res.Chars, "truncated", res.Truncated) + "kind", res.Kind, "pages", res.Pages, "chars", res.Chars, "truncated", res.Truncated) return cachedExtract{ Status: history.DocumentStatusExtracted, Kind: res.Kind, + Pages: res.Pages, Chars: res.Chars, Truncated: res.Truncated, Text: res.Text, @@ -427,10 +439,21 @@ func unreadable(reason string) cachedExtract { return cachedExtract{Status: history.DocumentStatusUnreadable, Reason: reason} } -// reasonForExtractError 把抽取器的哨兵错误映射为注入模型的诚实原因。 -func reasonForExtractError(err error) string { - if errors.Is(err, ErrNoTextLayer) { - return reasonNoTextLayer +// reasonForExtractError 把抽取器的哨兵错误映射为注入模型的诚实原因。ext 是存储名 +// 的扩展名:同一个 `ErrNoTextLayer` 在 PDF 与 Office 上是两件不同的事(前者需要 +// OCR,后者是本期不做解析),因此必须分开说(切片 B1)。 +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 } diff --git a/internal/runtime/documents/sidecar_read.go b/internal/runtime/documents/sidecar_read.go index 4098e38..ce1f52f 100644 --- a/internal/runtime/documents/sidecar_read.go +++ b/internal/runtime/documents/sidecar_read.go @@ -29,8 +29,10 @@ type Sidecar struct { Status string // Reason 是 Status == unreadable 时的诚实原因(正文位置为空)。 Reason string - // Kind 是抽取所得内容的种类(text / csv / tsv)。 + // Kind 是抽取所得内容的种类(text / csv / tsv / pdf)。 Kind string + // Pages 是 PDF 的页数(其它种类为 0)。 + Pages int // Chars 是抽取期写盘时的正文字符数(rune)。 Chars int // Truncated 报告 sidecar 正文在**抽取期**是否撞上了安全上限 @@ -68,6 +70,7 @@ func ReadSidecar(dataDir, name string) (Sidecar, error) { Status: rec.Status, Reason: rec.Reason, Kind: rec.Kind, + Pages: rec.Pages, Chars: rec.Chars, Truncated: rec.Truncated, Text: rec.Text, @@ -123,6 +126,7 @@ func (s *sidecarStore) loadSidecar(name string) (cachedExtract, error) { Status: meta.Status, Reason: meta.Reason, Kind: meta.Kind, + Pages: meta.Pages, Chars: meta.Chars, Truncated: meta.Truncated, Text: body, diff --git a/internal/runtime/documents/sidecar_test.go b/internal/runtime/documents/sidecar_test.go index 9a4b33c..a888feb 100644 --- a/internal/runtime/documents/sidecar_test.go +++ b/internal/runtime/documents/sidecar_test.go @@ -129,20 +129,24 @@ func TestResolver_KeepsFullBodyAndRecordsSafetyTruncationInSidecar(t *testing.T) } } -// TestResolver_UnreadableIsHonestAndCached 钉住失败可见(design/24 §4.4):PDF -// 得到 unreadable + 诚实原因,清单/回传状态有来源;失败同样只抽一次。 +// TestResolver_UnreadableIsHonestAndCached 钉住失败可见(design/24 §4.4):图片型/ +// 无文本层 PDF 得到 unreadable + **诚实原因**(必须点明「需要 OCR」),清单/回传 +// 状态有来源;失败同样只抽一次。 func TestResolver_UnreadableIsHonestAndCached(t *testing.T) { t.Parallel() 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}) ref, body := resolveOne(t, r, name) if ref.Status != history.DocumentStatusUnreadable { t.Fatalf("Status = %q, want %q", ref.Status, history.DocumentStatusUnreadable) } - if body != reasonNoTextLayer { - t.Fatalf("body = %q, want %q", body, reasonNoTextLayer) + if body != reasonPDFNoTextLayer { + 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 { 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 { 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) } } +// 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、也不是 // 静默:它是一条 unreadable 结论 + 诚实原因(回合照常继续)。 func TestResolver_MissingFileIsUnreadable(t *testing.T) { diff --git a/internal/runtime/documents/store.go b/internal/runtime/documents/store.go index a7c3ffe..85c2ccf 100644 --- a/internal/runtime/documents/store.go +++ b/internal/runtime/documents/store.go @@ -46,12 +46,19 @@ const ( // = 128 位)。读侧的名字形态校验与 sidecar 的键同名同宽。 HashHexLen = 32 - // SniffOKMIME 是唯一放行的嗅探结果**基类型**。白名单里每一种文档(文本、 - // 代码、CSV、JSON、YAML…)经 `http.DetectContentType` 嗅探后都落在 + // SniffOKMIME 是**纯文本类**唯一放行的嗅探结果基类型。白名单里的每一种文本 + // 文档(文本、代码、CSV、JSON、YAML…)经 `http.DetectContentType` 嗅探后都落在 // `text/plain`;HTML/XML/SVG/压缩包/可执行文件/判不定者各有自己的签名, - // 因此「不是 text/plain」即「拒绝」。 + // 因此「不是 text/plain」即「拒绝」。PDF 是唯一的例外:它有自己的签名,放行 + // 条件必须与扩展名**成对**判定(见 ContentAllowed)。 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 = ".document-*" @@ -70,8 +77,9 @@ const ( var ( // ErrExtNotAllowed 报告原始文件名的扩展名不在白名单内(deny-by-default)。 ErrExtNotAllowed = errors.New("documents: 扩展名不在白名单内") - // ErrContentNotText 报告字节嗅探否决了这份内容(基类型不是 text/plain)。 - ErrContentNotText = errors.New("documents: 内容不是纯文本类(字节嗅探否决)") + // ErrContentNotText 报告内容与扩展名对不上:字节嗅探否决了这份内容(纯文本类 + // 必须嗅探成 `text/plain`,`.pdf` 必须嗅探成 `application/pdf`)。 + ErrContentNotText = errors.New("documents: 内容与扩展名不符(字节嗅探否决)") // ErrTooLarge 报告内容超过生效的单文件字节上限。 ErrTooLarge = errors.New("documents: 内容超过字节上限") // ErrEmpty 报告内容为空(空文件没有落盘意义,也不该产生元数据行)。 @@ -195,9 +203,10 @@ func (s *Store) Limit() int { // 拒绝(`ErrExtNotAllowed`)—— 嗅探成 `text/plain` 的 `.js`/`.py` 也在此列; // 2. **空内容**拒绝(`ErrEmpty`); // 3. **字节上限**拒绝(`ErrTooLarge`,上限见 Limit); -// 4. **字节嗅探唯一裁决**:`http.DetectContentType` 的基类型必须是 `text/plain`, -// 否则拒绝(`ErrContentNotText`)—— 客户端声明的 MIME 与文件名只作参考、 -// 可被否决; +// 4. **字节嗅探唯一裁决**:`http.DetectContentType` 的基类型必须与该扩展名 +// 匹配(`ContentAllowed`):纯文本类是 `text/plain`,`.pdf` 是 +// `application/pdf`(HTML 改名成 `.pdf` 因此仍被拒),否则拒绝 +// (`ErrContentNotText`)—— 客户端声明的 MIME 与文件名只作参考、可被否决; // 5. 内容寻址落盘 + 路径双检 + 去重(同字节同扩展名 ⇒ 同名 ⇒ `Deduped=true`), // 随后(可选)写一行 `documents` 元数据。 // @@ -232,8 +241,8 @@ func (s *Store) prepare(origName, sniffed string, data []byte) (Ref, error) { if limit := s.Limit(); len(data) > limit { return Ref{}, fmt.Errorf("documents: save: 上限 %d 字节,本次 %d 字节: %w", limit, len(data), ErrTooLarge) } - if !SniffAllowed(sniffed) { - return Ref{}, fmt.Errorf("documents: save: 嗅探 %q 非纯文本: %w", baseMIME(sniffed), ErrContentNotText) + if !ContentAllowed(ext, sniffed) { + return Ref{}, fmt.Errorf("documents: save: 扩展名 %q 与嗅探 %q 不匹配: %w", ext, baseMIME(sniffed), ErrContentNotText) } sum := sha256.Sum256(data) return Ref{ @@ -323,25 +332,44 @@ func ExtOf(filename string) string { // `.ps1`/`.js`/`.mjs`/`.sh`/`.py`…)一律拒绝。注意 `.js`/`.py` 即便嗅探成 // `text/plain` 也必须拒绝 —— 判定以扩展名与嗅探的**合取**为准。 // -// 这 22 项是 design/24 §9.2-2 的定稿集合,全仓只此一份(网关的名字形态校验 -// 与 IM 入站附件都经它判定)。 +// 这 23 项 = design/24 §9.2-2 定稿的 22 项文本类 + `.pdf`(切片 B1 纳入), +// 全仓只此一份(网关的名字形态校验与 IM 入站附件都经它判定)。`.pdf` 的放行 +// **附带内容条件**:它只收真 PDF,见 ContentAllowed。 func ExtAllowed(ext string) bool { switch ext { case ".txt", ".md", ".markdown", ".csv", ".tsv", ".json", ".jsonl", ".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 default: return false } } -// SniffAllowed 报告嗅探所得类型是否落在放行的文本类内。它是「类型裁决的唯一 -// 裁决者」在代码里的落点:客户端声明的 `Content-Type` 从不参与决定。 +// SniffAllowed 报告嗅探所得类型是否落在放行的**纯文本类**内。它是「类型裁决的 +// 唯一裁决者」在代码里的落点:客户端声明的 `Content-Type` 从不参与决定。 +// +// 它只管纯文本那一半;完整的成对裁决是 ContentAllowed(写侧与读侧都走它)。 func SniffAllowed(sniffed string) bool { 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。 // 同一份字节配同一个扩展名必然得到同一个名字,去重因此是「同名」的自然结果。 func hashName(sum []byte, ext string) string { diff --git a/internal/runtime/documents/store_test.go b/internal/runtime/documents/store_test.go index f8db790..4d5a04f 100644 --- a/internal/runtime/documents/store_test.go +++ b/internal/runtime/documents/store_test.go @@ -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("hi") + 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) { diff --git a/internal/services/gateway/chat_documents.go b/internal/services/gateway/chat_documents.go index 24a6b42..ec82fea 100644 --- a/internal/services/gateway/chat_documents.go +++ b/internal/services/gateway/chat_documents.go @@ -102,7 +102,7 @@ func (s *Server) resolveChatDocument(ref chatDocumentRef) (model.Document, error } return model.Document{ Name: ref.Name, - MIME: documentReadMIME(f), + MIME: documentReadMIME(f, ref.Name), URL: documentFilePrefix + ref.Name, Size: info.Size(), }, nil diff --git a/internal/services/gateway/documents.go b/internal/services/gateway/documents.go index b5b6349..afa62d4 100644 --- a/internal/services/gateway/documents.go +++ b/internal/services/gateway/documents.go @@ -72,10 +72,12 @@ const ( // documentsLogSource 是文档子系统的日志来源标签。 documentsLogSource = "gateway/documents" - // documentSniffOKMIME 是唯一放行的嗅探结果基类型。白名单里的每一种文档 - // (文本、代码、CSV、JSON、YAML…)经 `http.DetectContentType` 嗅探后都落在 + // documentSniffOKMIME 是**纯文本类**放行的嗅探结果基类型。白名单里的文本类 + // 文档(文本、代码、CSV、JSON、YAML…)经 `http.DetectContentType` 嗅探后都落在 // `text/plain`;HTML/XML/SVG/压缩包/可执行文件/判不定者各有自己的签名, // 因此「不是 text/plain」即「拒绝」。取值取自共享存储核心(同一裁决)。 + // PDF(切片 B1)是唯一的例外:它有 `application/pdf` 这个自己的签名,放行 + // 条件必须与 `.pdf` 扩展名**成对**判定(`documents.ContentAllowed`)。 documentSniffOKMIME = documents.SniffOKMIME // documentFallbackMIME 是读侧嗅探不出放行类型时下发的媒体类型。配合 @@ -126,7 +128,8 @@ func documentExtOf(filename string) string { // 一切(`.html`/`.svg`/`.xml`/`.zip`/`.docx`/`.xlsx`/`.exe`/`.dll`/`.bat`/ // `.cmd`/`.ps1`/`.js`/`.mjs`/`.sh`/`.py`…)一律拒绝。注意 `.js`/`.py` 即便 // 嗅探成 `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 { return documents.ExtAllowed(ext) } @@ -141,11 +144,12 @@ func documentBaseMIME(sniffed string) string { return strings.ToLower(strings.TrimSpace(strings.Split(sniffed, ";")[0])) } -// documentSniffAllowed 报告嗅探所得类型是否落在放行的文本类内。它是「类型裁决 +// documentSniffAllowed 报告 (扩展名, 嗅探结果) 这一对是否被放行。它是「类型裁决 // 的唯一裁决者」在代码里的落点:客户端声明的 `Content-Type` 从不参与决定。 -// 裁决本体只存在于 `documents.SniffAllowed`(写侧与读侧同一份)。 -func documentSniffAllowed(sniffed string) bool { - return documents.SniffAllowed(sniffed) +// 裁决本体只存在于 `documents.ContentAllowed`(写侧与读侧同一份)—— 成对判定, +// 因为 `.pdf` 的放行条件是 `application/pdf` 而不是 `text/plain`(切片 B1)。 +func documentSniffAllowed(ext, sniffed string) bool { + return documents.ContentAllowed(ext, sniffed) } // validDocumentName 报告 name 是否恰好是 32 位小写十六进制主名加白名单文档 @@ -356,7 +360,7 @@ func (s *Server) handleDocumentFile(w http.ResponseWriter, r *http.Request) { writeError(w, documentNotFoundError()) 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("X-Content-Type-Options", "nosniff") // 内容寻址 ⇒ 名字变则内容变,可以放心 immutable。 @@ -365,8 +369,11 @@ func (s *Server) handleDocumentFile(w http.ResponseWriter, r *http.Request) { } // documentReadMIME 嗅探已存文档的头部并给出可安全下发的媒体类型。它不读数据库: -// 落盘内容就是唯一真源,嗅探不出放行类型时一律 application/octet-stream。 -func documentReadMIME(f *os.File) string { +// 落盘内容就是唯一真源;`name` 只用来取扩展名 —— 类型裁决是**成对**的 +// (`documents.ContentAllowed`),因此真 PDF 会如实下发 `application/pdf` +// (配合 `attachment` + `nosniff`,浏览器仍不会内联渲染),判不定者与原响应一致 +// 地退化为 `application/octet-stream`。 +func documentReadMIME(f *os.File, name string) string { buf := make([]byte, uploadSniffBytes) n, err := io.ReadFull(f, buf) 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 } sniffed := http.DetectContentType(buf[:n]) - if !documentSniffAllowed(sniffed) { + if !documentSniffAllowed(documents.ExtOf(name), sniffed) { return documentFallbackMIME } return documentBaseMIME(sniffed) @@ -398,10 +405,11 @@ func documentTooLargeError(limit int) error { } // documentUnsupportedError 构造类型拒绝错误:415 + unsupported_media_type。 -// 文案刻意同时点明「扩展名」与「内容」两条口径,因为任一不过都走这里。 +// 文案刻意同时点明「扩展名」与「内容」两条口径,因为任一不过都走这里; +// PDF 是唯一「扩展名与内容必须互相对应」的放行格式,因此文案里点出它。 func documentUnsupportedError() error { return apperr.Wrap(apperr.ErrUnsupportedMediaType, documentUnsupportedCode, - "只接受白名单内的纯文本类文档(如 .txt/.md/.csv/.json),且内容必须确为文本") + "只接受白名单内的文档(如 .txt/.md/.csv/.json,或内容确为 PDF 的 .pdf),且内容必须与扩展名相符") } // documentNotFoundError 构造文档缺失错误:404 + document_not_found。名字形态 diff --git a/internal/services/gateway/documents_pdf_test.go b/internal/services/gateway/documents_pdf_test.go new file mode 100644 index 0000000..6a7803f --- /dev/null +++ b/internal/services/gateway/documents_pdf_test.go @@ -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("hi")}, + {"纯文本改名成 .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) + } + } +}