Files
Cyrene_v2/internal/services/gateway/documents_pdf_test.go
T
AskaEth f82c76df27
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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 与新的边界。
2026-10-04 19:46:36 +08:00

105 lines
4.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}
}