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
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user