feat(documents): PDF 文本层抽取(S6 第一段;扫描件仍诚实拒绝)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 依赖:github.com/ledongthuc/pdf(**BSD-3-Clause**,非 MIT —— 子代理核了 LICENSE 并纠正了任务书的写法;零传递依赖;无 cgo/无外部进程;体积 strip 后 +285.5 KiB / +0.74%,在 detached worktree 里量取,产出写到仓库外)。被否候选:pdfcpu 无文本抽取、rsc.io/pdf 停更、pdfium/poppler/mupdf 需 cgo 撞红线。

- 实现:魔数 %PDF- 是唯一入口判定(名字/声明指向 PDF 而字节无魔数 ⇒ ErrUnsupported,绝不退回纯文本抽);逐页 GetPlainText(页间仅去首尾空白);ExtractorVersion v2→v3(所有 sidecar 一次性重抽);页数上限 5000 + 字节安全上限复用;新增哨兵 ErrPDFEncrypted / ErrPDFDamaged。

- 诚实边界:无文本层(扫描件)⇒ ErrNoTextLayer 且文案含「图片型/无文本层 PDF,需要 OCR(当前未做)」;加密/损坏各自可判、绝不 panic、不回空正文假装成功。

- 白名单与成对裁决:.pdf 纳入(22→23),且 ContentAllowed 做双向裁决(.pdf 只收嗅探为 application/pdf;PDF 字节只收在 .pdf 名下);读侧真 PDF 如实下发 application/pdf,仍 attachment + nosniff(不可内联)。

- 1 处 panic 兜底已如实登记:该库对「xref 指向文件外」畸形件会在 NumPage() 直接 panic(已用仓库外程序复现),单二进制常驻后端不能因 587 字节上传件崩掉 ⇒ defer recover 折成 ErrPDFDamaged;损坏类主用例仍走库的真出错路径。

- 主会话独立验收:隔离 worktree(B1 的 14 文件)build/vet 绿、全树 59 包 0 FAIL、PDF 用例 13 PASS、双平台交叉编译绿;变异独立复现(魔数裁决短路 ⇒ 魔数/成对裁决用例红)。

- ⚠️ 契约漂移(已排 C2):Docs 仍写「22 项白名单 / 嗅探仅放行 text/plain」,与本切片的 23 项 + 成对裁决不一致 ⇒ 需升 v0.61 登记 .pdf 与新的边界。
This commit is contained in:
AskaEth committed 2026-10-04 19:46:36 +08:00
1 parent c4e09afd38
commit f82c76df27
14 files changed
+926 -94

No files matched your search

+1 -1
View File
@@ -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
+21 -13
View File
@@ -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)
}
}
}