- 依赖: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 与新的边界。
105 lines
4.6 KiB
Go
105 lines
4.6 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|