feat(documents): PDF 文本层抽取(S6 第一段;扫描件仍诚实拒绝)
- 依赖:github.com/ledongthuc/pdf(**BSD-3-Clause**,非 MIT —— 子代理核了 LICENSE 并纠正了任务书的写法;零传递依赖;无 cgo/无外部进程;体积 strip 后 +285.5 KiB / +0.74%,在 detached worktree 里量取,产出写到仓库外)。被否候选:pdfcpu 无文本抽取、rsc.io/pdf 停更、pdfium/poppler/mupdf 需 cgo 撞红线。
- 实现:魔数 %PDF- 是唯一入口判定(名字/声明指向 PDF 而字节无魔数 ⇒ ErrUnsupported,绝不退回纯文本抽);逐页 GetPlainText(页间仅去首尾空白);ExtractorVersion v2→v3(所有 sidecar 一次性重抽);页数上限 5000 + 字节安全上限复用;新增哨兵 ErrPDFEncrypted / ErrPDFDamaged。
- 诚实边界:无文本层(扫描件)⇒ ErrNoTextLayer 且文案含「图片型/无文本层 PDF,需要 OCR(当前未做)」;加密/损坏各自可判、绝不 panic、不回空正文假装成功。
- 白名单与成对裁决:.pdf 纳入(22→23),且 ContentAllowed 做双向裁决(.pdf 只收嗅探为 application/pdf;PDF 字节只收在 .pdf 名下);读侧真 PDF 如实下发 application/pdf,仍 attachment + nosniff(不可内联)。
- 1 处 panic 兜底已如实登记:该库对「xref 指向文件外」畸形件会在 NumPage() 直接 panic(已用仓库外程序复现),单二进制常驻后端不能因 587 字节上传件崩掉 ⇒ defer recover 折成 ErrPDFDamaged;损坏类主用例仍走库的真出错路径。
- 主会话独立验收:隔离 worktree(B1 的 14 文件)build/vet 绿、全树 59 包 0 FAIL、PDF 用例 13 PASS、双平台交叉编译绿;变异独立复现(魔数裁决短路 ⇒ 魔数/成对裁决用例红)。
- ⚠️ 契约漂移(已排 C2):Docs 仍写「22 项白名单 / 嗅探仅放行 text/plain」,与本切片的 23 项 + 成对裁决不一致 ⇒ 需升 v0.61 登记 .pdf 与新的边界。
This commit is contained in:
1 parent
c4e09afd38
commit
f82c76df27
14 files changed
+926
-94
No files matched your search
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -21,19 +21,31 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
||||
)
|
||||
|
||||
// whitelistExtensions 是 design/24 §9.2-2 定稿的 22 项白名单。**独立列一遍**,
|
||||
// 使「白名单被悄悄改宽/改窄」这件事有用例能发现(值本身必须钉住,而不只是
|
||||
// 「白名单里的项能被放行」这种自洽测试)。
|
||||
// whitelistExtensions 是 design/24 §9.2-2 定稿的 22 项**文本类**白名单 + `.pdf`
|
||||
// (切片 B1 纳入,共 23 项)。**独立列一遍**,使「白名单被悄悄改宽/改窄」这件事
|
||||
// 有用例能发现(值本身必须钉住,而不只是「白名单里的项能被放行」这种自洽测试)。
|
||||
var whitelistExtensions = []string{
|
||||
".txt", ".md", ".markdown", ".csv", ".tsv", ".json", ".jsonl",
|
||||
".yaml", ".yml", ".log", ".ini", ".cfg", ".toml",
|
||||
".go", ".ts", ".tsx", ".java", ".c", ".h", ".cpp", ".rs", ".sql",
|
||||
PDFExt,
|
||||
}
|
||||
|
||||
// deniedExtensions 是必须被拒的扩展名(含「嗅探成 text/plain 也不放行」的脚本类)。
|
||||
var deniedExtensions = []string{
|
||||
".html", ".svg", ".xml", ".zip", ".docx", ".xlsx", ".exe", ".dll",
|
||||
".bat", ".cmd", ".ps1", ".js", ".mjs", ".sh", ".py", ".pdf", "",
|
||||
".bat", ".cmd", ".ps1", ".js", ".mjs", ".sh", ".py", "",
|
||||
}
|
||||
|
||||
// whitelistSample 给每个白名单扩展名一份**该类型真实成立**的样本字节:文本类用
|
||||
// 一行 UTF-8 文本,`.pdf` 用现场拼出的合法 PDF(`.pdf` 只收真 PDF,占位文本会被
|
||||
// 成对裁决拒掉 —— 那正是切片 B1 的行为)。
|
||||
func whitelistSample(t *testing.T, ext string) []byte {
|
||||
t.Helper()
|
||||
if ext == PDFExt {
|
||||
return buildPDF(t, pdfFixture{Contents: []string{textContent("whitelist sample")}})
|
||||
}
|
||||
return []byte("昔涟的测试文本 " + ext + "\n")
|
||||
}
|
||||
|
||||
// newTestStore 构造一个落在临时目录的 Store(默认上限)。
|
||||
@@ -53,12 +65,12 @@ func expectedName(t *testing.T, ext string, data []byte) (string, string) {
|
||||
return hexSum[:HashHexLen] + ext, hexSum
|
||||
}
|
||||
|
||||
// TestStoreSaveAcceptsWhitelistedExtensions 覆盖验收第 1 条:22 项白名单逐项放行,
|
||||
// TestStoreSaveAcceptsWhitelistedExtensions 覆盖验收第 1 条:23 项白名单逐项放行,
|
||||
// 落盘名是内容寻址名、MIME 是嗅探基类型、内容逐字节一致。
|
||||
func TestStoreSaveAcceptsWhitelistedExtensions(t *testing.T) {
|
||||
t.Parallel()
|
||||
if len(whitelistExtensions) != 22 {
|
||||
t.Fatalf("白名单项数 = %d, want 22(design/24 §9.2-2 定稿)", len(whitelistExtensions))
|
||||
if len(whitelistExtensions) != 23 {
|
||||
t.Fatalf("白名单项数 = %d, want 23(design/24 §9.2-2 的 22 项文本类 + .pdf)", len(whitelistExtensions))
|
||||
}
|
||||
store := newTestStore(t, StoreConfig{})
|
||||
ctx := context.Background()
|
||||
@@ -66,7 +78,7 @@ func TestStoreSaveAcceptsWhitelistedExtensions(t *testing.T) {
|
||||
if !ExtAllowed(ext) {
|
||||
t.Fatalf("ExtAllowed(%q) = false, want true(白名单缺项或拼写变了)", ext)
|
||||
}
|
||||
data := []byte("昔涟的测试文本 " + ext + "\n")
|
||||
data := whitelistSample(t, ext)
|
||||
wantName, wantSHA := expectedName(t, ext, data)
|
||||
ref, err := store.Save(ctx, "orig"+ext, data)
|
||||
if err != nil {
|
||||
@@ -75,9 +87,13 @@ func TestStoreSaveAcceptsWhitelistedExtensions(t *testing.T) {
|
||||
if ref.Name != wantName || ref.SHA256 != wantSHA {
|
||||
t.Fatalf("%s: name/sha = (%q,%q), want (%q,%q)", ext, ref.Name, ref.SHA256, wantName, wantSHA)
|
||||
}
|
||||
if ref.MIME != SniffOKMIME || ref.Size != int64(len(data)) || ref.Deduped {
|
||||
wantMIME := SniffOKMIME
|
||||
if ext == PDFExt {
|
||||
wantMIME = PDFMIME
|
||||
}
|
||||
if ref.MIME != wantMIME || ref.Size != int64(len(data)) || ref.Deduped {
|
||||
t.Fatalf("%s: mime/size/deduped = (%q,%d,%v), want (%q,%d,false)",
|
||||
ext, ref.MIME, ref.Size, ref.Deduped, SniffOKMIME, len(data))
|
||||
ext, ref.MIME, ref.Size, ref.Deduped, wantMIME, len(data))
|
||||
}
|
||||
stored, err := os.ReadFile(ref.Path)
|
||||
if err != nil {
|
||||
@@ -89,6 +105,40 @@ func TestStoreSaveAcceptsWhitelistedExtensions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestStoreSavePDFPairing 覆盖验收第 4 条的反向:`.pdf` **只收真 PDF**,真 PDF 也
|
||||
// 只收在 `.pdf` 名下 —— 把 HTML 改名成 `.pdf` 仍被字节嗅探否决;限额对 `.pdf`
|
||||
// 同样生效(判定顺序仍是 上限 → 嗅探)。
|
||||
func TestStoreSavePDFPairing(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := newTestStore(t, StoreConfig{})
|
||||
ctx := context.Background()
|
||||
html := []byte("<!DOCTYPE html><html><body>hi</body></html>")
|
||||
if _, err := store.Save(ctx, "lie.pdf", html); !errors.Is(err, ErrContentNotText) {
|
||||
t.Fatalf("HTML 改名成 .pdf: err = %v, want ErrContentNotText", err)
|
||||
}
|
||||
if _, err := store.Save(ctx, "blank.pdf", []byte("只是一段普通文本\n")); !errors.Is(err, ErrContentNotText) {
|
||||
t.Fatalf("文本改名成 .pdf: err = %v, want ErrContentNotText", err)
|
||||
}
|
||||
pdfBytes := buildPDF(t, pdfFixture{Contents: []string{textContent("real pdf")}})
|
||||
if _, err := store.Save(ctx, "doc.txt", pdfBytes); !errors.Is(err, ErrContentNotText) {
|
||||
t.Fatalf("PDF 改名成 .txt: err = %v, want ErrContentNotText", err)
|
||||
}
|
||||
ref, err := store.Save(ctx, "report.pdf", pdfBytes)
|
||||
if err != nil {
|
||||
t.Fatalf("真 PDF 上传: %v", err)
|
||||
}
|
||||
if ref.MIME != PDFMIME || !strings.HasSuffix(ref.Name, PDFExt) {
|
||||
t.Fatalf("真 PDF 的落盘 = (%q,%q), want (%q,%s 结尾)", ref.Name, ref.MIME, PDFMIME, PDFExt)
|
||||
}
|
||||
|
||||
const limit = 1024
|
||||
small := newTestStore(t, StoreConfig{MaxBytes: limit})
|
||||
over := append([]byte(pdfMagic), bytes.Repeat([]byte{'a'}, limit)...)
|
||||
if _, err := small.Save(ctx, "big.pdf", over); !errors.Is(err, ErrTooLarge) {
|
||||
t.Fatalf("超限 PDF: err = %v, want ErrTooLarge", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStoreSaveRejectsDisallowedExtensions 覆盖验收第 1 条的拒绝侧:deny-by-default,
|
||||
// 含 `.js`/`.py`/`.sh` 这类「内容确是纯文本但扩展名不在白名单」的样本。
|
||||
func TestStoreSaveRejectsDisallowedExtensions(t *testing.T) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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。名字形态
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user