Files
Cyrene_v2/internal/runtime/documents/store.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

378 lines
17 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 documents
// 本文件归属**文档存储核心**(design/24 §3.2;切片 M1):把 S1 原本长在网关
// `internal/services/gateway/documents.go` 里的存储规则(字节嗅探唯一裁决 +
// 扩展名白名单 + 内容寻址 + 去重 + 限额 + 元数据)抽成本包的**一份**实现,
// 供两条来源路径共用:
//
// - Web 上传(`POST /api/v1/documents`,网关调用);
// - IM 入站附件(channelbridge 把消息里的文件类媒体交给这里,切片 M1)。
//
// 为什么必须先抽出来:规则里有安全裁决(嗅探否决、白名单、路径双检),在桥接器
// 里再写一套必然漂移;而 `<DataDir>/uploads/documents` 与 `documents` 元数据表
// 也必须是**唯一写入面**,S7 的引用计数与 GC 才有单一事实来源。
//
// 落盘规则与 S1 逐字同源:客户端原始文件名**永不参与路径**,落盘名是
// `sha256(bytes)[:32] + ext`,同字节同扩展名必然同名、天然去重;目录 0700、
// 文件 0600(继承临时文件)。读侧(`GET /api/v1/documents/{name}`)与装配期
// 摄取(Resolver)不改,仍旧只认这个形态的名字。
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
)
const (
// DefaultMaxBytes 是文档单文件字节的**默认上限**(20 MiB,即
// `CHAT_DOCUMENT_MAX_BYTES` 的默认值)。它只在这里定义一次:网关的
// `maxDocumentBytes`、配置的 default 标签与 IM 入站附件都从它派生,绝不
// 在别处再写一个数(design/24 §3.3:文档上限与图片上限刻意解耦)。
DefaultMaxBytes = 20 << 20
// MaxPerMessage 是**单条消息**(一个回合)可携带的附件数上限
// (design/24 §9.4-3 的定稿值 5)。网关的单回合引用上限与 IM 入站附件
// 的上限同源于它。
MaxPerMessage = 5
// HashHexLen 是内容寻址落盘名使用的小写十六进制主名长度(32 位十六进制
// = 128 位)。读侧的名字形态校验与 sidecar 的键同名同宽。
HashHexLen = 32
// SniffOKMIME 是**纯文本类**唯一放行的嗅探结果基类型。白名单里的每一种文本
// 文档(文本、代码、CSV、JSON、YAML…)经 `http.DetectContentType` 嗅探后都落在
// `text/plain`;HTML/XML/SVG/压缩包/可执行文件/判不定者各有自己的签名,
// 因此「不是 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-*"
// dirMode 是文档目录权限;文件权限继承 `os.CreateTemp` 的 0600。
dirMode = 0o700
// uploadsSubDir / documentSubDir 是 `<DataDir>` 之下的默认布局:
// `<DataDir>/uploads/documents`。与网关的 `documentSubDir`、组合根给摄取层
// 的 UploadsDir 同源,改一处必须同步(否则装配期会去读一个不存在的目录)。
uploadsSubDir = "uploads"
documentSubDir = "documents"
)
// 存储侧的哨兵错误。调用方一律用 `errors.Is` 分类,**绝不按错误文本匹配**:
// 网关把它们映射为既有的 415 / 413 / 400 契约错误码,IM 侧映射为诚实提示。
var (
// ErrExtNotAllowed 报告原始文件名的扩展名不在白名单内(deny-by-default)。
ErrExtNotAllowed = errors.New("documents: 扩展名不在白名单内")
// ErrContentNotText 报告内容与扩展名对不上:字节嗅探否决了这份内容(纯文本类
// 必须嗅探成 `text/plain`,`.pdf` 必须嗅探成 `application/pdf`)。
ErrContentNotText = errors.New("documents: 内容与扩展名不符(字节嗅探否决)")
// ErrTooLarge 报告内容超过生效的单文件字节上限。
ErrTooLarge = errors.New("documents: 内容超过字节上限")
// ErrEmpty 报告内容为空(空文件没有落盘意义,也不该产生元数据行)。
ErrEmpty = errors.New("documents: 内容为空")
// ErrNoDir 报告文档落盘目录未配置(组合根/网关都没给出 UploadsDir 与 DataDir)。
ErrNoDir = errors.New("documents: 文档落盘目录未配置")
// ErrBadPath 报告拼出的落盘路径逃出了文档目录(内容寻址名由本包签发,
// 因此这是防御性的第二道门)。
ErrBadPath = errors.New("documents: 落盘路径逃出文档目录")
// ErrMetadata 报告元数据写入失败。它与「文件已经落盘」是两个事实:调用方
// 据它把 500 文案分开(S1 的「记录文档元数据失败」),并知道盘上那份文件
// 需要 S7 的 GC 容忍或清理(design/24 §9.2-5)。
ErrMetadata = errors.New("documents: 元数据写入失败")
)
// Ref 是一次成功落盘的结果:服务端签发的引用 + 落盘路径。前五个字段是调用方
// 对外呈现的事实(网关的响应字段、IM 侧的展示身份),Path 只给服务端自己用
// (日志/清理),**绝不进模型上下文、绝不回给客户端**。
type Ref struct {
// Name 是内容寻址的存储名(`sha256(bytes)[:32] + ext`)。
Name string
// MIME 是字节嗅探所得的基类型(去参数、小写)。
MIME string
// Size 是落盘字节数。
Size int64
// SHA256 是完整内容的十六进制摘要(落盘名只取前 HashHexLen 位)。
SHA256 string
// Deduped 报告这次写入命中了盘上已有的同一份内容(同字节同扩展名)。
Deduped bool
// Path 是落盘绝对/相对路径(按构造方给的 UploadsDir 决定形态)。
Path string
}
// Record 是写入 `documents` 表(迁移 0014)的一行元数据。列与 S1 逐字一致。
type Record struct {
// Name 是内容寻址的存储名(表上的唯一键)。
Name string
// SHA256 是完整内容的十六进制摘要。
SHA256 string
// MIME 是嗅探所得的基类型。
MIME string
// Size 是字节数。
Size int64
// Uploader 是上传者身份:网关给请求 claims 的 sub,IM 给渠道来源标签。
// 身份**只由构造方给出**,store 绝不从文件名/内容里猜(项目红线)。
Uploader string
}
// MetadataWriter 是元数据写入的**可选注入面**(design/24 §9.2-5 的技术债:
// 网关当前每次上传 `db.Open` 开新连接,本切片不把它改成连接注入)。
// 未注入时 Save 照旧成功,只跳过写表并留一条 DEBUG 痕迹。
type MetadataWriter interface {
// WriteDocument 写入一行文档元数据。同一存储名重复上传由实现方
// `ON CONFLICT(name) DO NOTHING`(首次的 uploader/created_at 是权威)。
WriteDocument(ctx context.Context, rec Record) error
}
// StoreConfig 是文档存储核心的接线参数。路径口径由调用方给出,本包不猜数据
// 目录布局(与摄取层 Config 同一理由)。
type StoreConfig struct {
// DataDir 是数据目录。UploadsDir 留空时文档落进
// `<DataDir>/uploads/documents`(网关与组合根用的是同一布局)。
DataDir string
// UploadsDir 是文档落盘的**完整目录**。非空时优先于 DataDir 推导。
UploadsDir string
// MaxBytes 是单文件字节上限;≤0 时取 DefaultMaxBytes。口径是
// 「≤0 = 用默认」而不是「禁用上限」,与网关的 documentLimitBytes 一致。
MaxBytes int
// Uploader 是写进元数据的上传者身份(见 Record.Uploader)。
Uploader string
// Meta 是元数据写入的可选注入面;nil 时跳过写表并留痕。
Meta MetadataWriter
// Log 是基础 logger;nil 时替换为标记来源 runtime/documents 的丢弃 logger。
Log *slog.Logger
}
// Store 是文档落盘的唯一实现。它可安全并发使用(无可变状态,落盘的竞态由
// 「临时文件 + rename」与「已存在即去重」处理)。
type Store struct {
dir string
maxBytes int
uploader string
meta MetadataWriter
log *slog.Logger
}
// NewStore 构造文档存储。UploadsDir 与 DataDir 都为空 ⇒ 每次 Save 都返回
// ErrNoDir(宁可明确失败,也不猜一个目录出来)。
func NewStore(cfg StoreConfig) *Store {
dir := strings.TrimSpace(cfg.UploadsDir)
if dir == "" {
if root := strings.TrimSpace(cfg.DataDir); root != "" {
dir = filepath.Join(root, uploadsSubDir, documentSubDir)
}
}
return &Store{
dir: dir,
maxBytes: cfg.MaxBytes,
uploader: strings.TrimSpace(cfg.Uploader),
meta: cfg.Meta,
log: sourcedLog(cfg.Log),
}
}
// Dir 返回生效的文档落盘目录(未配置时为空串)。它给测试与日志用,不参与落盘。
func (s *Store) Dir() string { return s.dir }
// Limit 返回本次生效的单文件字节上限(≤0 归一为 DefaultMaxBytes)。
func (s *Store) Limit() int {
if s.maxBytes <= 0 {
return DefaultMaxBytes
}
return s.maxBytes
}
// Save 把一份原始文件落进文档存储,返回服务端签发的引用。规则与 S1 逐字同源,
// 判定顺序也刻意保持(扩展名 → 空 → 上限 → 嗅探),因为「同一次上传里两条规则
// 都不过时给哪个错误」是对外可见的行为:
//
// 1. **扩展名白名单**(deny-by-default):原始文件名的扩展名不在放行集合内一律
// 拒绝(`ErrExtNotAllowed`)—— 嗅探成 `text/plain` 的 `.js`/`.py` 也在此列;
// 2. **空内容**拒绝(`ErrEmpty`);
// 3. **字节上限**拒绝(`ErrTooLarge`,上限见 Limit);
// 4. **字节嗅探唯一裁决**:`http.DetectContentType` 的基类型必须与该扩展名
// 匹配(`ContentAllowed`):纯文本类是 `text/plain`,`.pdf` 是
// `application/pdf`(HTML 改名成 `.pdf` 因此仍被拒),否则拒绝
// (`ErrContentNotText`)—— 客户端声明的 MIME 与文件名只作参考、可被否决;
// 5. 内容寻址落盘 + 路径双检 + 去重(同字节同扩展名 ⇒ 同名 ⇒ `Deduped=true`),
// 随后(可选)写一行 `documents` 元数据。
//
// 元数据写入失败时返回的错误同时满足 `errors.Is(err, ErrMetadata)`,且**已落盘的
// 引用照旧返回**:文件确实在盘上,调用方据此如实上报(S1 的行为)。
func (s *Store) Save(ctx context.Context, origName string, data []byte) (Ref, error) {
sniffed := http.DetectContentType(data)
ref, err := s.prepare(origName, sniffed, data)
if err != nil {
return Ref{}, err
}
committed, err := s.commit(ref, data)
if err != nil {
return Ref{}, err
}
if err := s.record(ctx, committed); err != nil {
return committed, err
}
return committed, nil
}
// prepare 执行落盘之前的全部裁决并算出内容寻址引用(不触盘、不建目录)。
// sniffed 由调用方传入,使「嗅探发生在任何磁盘动作之前」这一顺序在代码里可见。
func (s *Store) prepare(origName, sniffed string, data []byte) (Ref, error) {
ext := ExtOf(origName)
if !ExtAllowed(ext) {
return Ref{}, fmt.Errorf("documents: save: %w", ErrExtNotAllowed)
}
if len(data) == 0 {
return Ref{}, fmt.Errorf("documents: save: %w", ErrEmpty)
}
if limit := s.Limit(); len(data) > limit {
return Ref{}, fmt.Errorf("documents: save: 上限 %d 字节,本次 %d 字节: %w", limit, len(data), ErrTooLarge)
}
if !ContentAllowed(ext, sniffed) {
return Ref{}, fmt.Errorf("documents: save: 扩展名 %q 与嗅探 %q 不匹配: %w", ext, baseMIME(sniffed), ErrContentNotText)
}
sum := sha256.Sum256(data)
return Ref{
Name: hashName(sum[:], ext),
MIME: baseMIME(sniffed),
Size: int64(len(data)),
SHA256: hex.EncodeToString(sum[:]),
}, nil
}
// commit 把字节落进 `<dir>/<name>`:先写点前缀临时文件再 rename(部分写入绝不会
// 以可读的名字露面),目标名已存在时丢弃临时文件并报告 deduped —— 盘上只留一份。
func (s *Store) commit(ref Ref, data []byte) (Ref, error) {
if s.dir == "" {
return Ref{}, fmt.Errorf("documents: commit: %w", ErrNoDir)
}
path, err := targetPath(s.dir, ref.Name)
if err != nil {
return Ref{}, err
}
if info, serr := os.Stat(path); serr == nil && !info.IsDir() {
ref.Path, ref.Deduped = path, true
return ref, nil
}
if err := os.MkdirAll(s.dir, dirMode); err != nil {
return Ref{}, fmt.Errorf("documents: 建目录: %w", err)
}
tmp, err := os.CreateTemp(s.dir, tempPattern)
if err != nil {
return Ref{}, fmt.Errorf("documents: 建临时文件: %w", err)
}
tmpName := tmp.Name()
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
_ = os.Remove(tmpName)
return Ref{}, fmt.Errorf("documents: 写临时文件: %w", err)
}
if err := tmp.Close(); err != nil {
_ = os.Remove(tmpName)
return Ref{}, fmt.Errorf("documents: 关临时文件: %w", err)
}
if err := os.Rename(tmpName, path); err != nil {
_ = os.Remove(tmpName)
return Ref{}, fmt.Errorf("documents: 提交落盘: %w", err)
}
ref.Path = path
return ref, nil
}
// record 写入一行 `documents` 元数据(可选注入面)。未注入时跳过并留痕:
// 文件已经落盘,元数据留待接线(S1 在 `dbPath == ""` 时的行为)。
func (s *Store) record(ctx context.Context, ref Ref) error {
if s.meta == nil {
s.log.Debug("document metadata skipped: writer not wired",
"name", ref.Name, "size", ref.Size, "deduped", ref.Deduped)
return nil
}
rec := Record{
Name: ref.Name, SHA256: ref.SHA256, MIME: ref.MIME,
Size: ref.Size, Uploader: s.uploader,
}
if err := s.meta.WriteDocument(ctx, rec); err != nil {
return fmt.Errorf("documents: 元数据 %q: %w: %w", ref.Name, ErrMetadata, err)
}
return nil
}
// targetPath 按落盘名拼出路径并做 S1 的**路径双检**:拼出的路径必须仍在文档
// 目录内。名字由本包签发(内容寻址),因此这是防御性的第二道门 —— 与读侧
// `filepath.Dir(path) != filepath.Clean(dir)` 是同一手法。
func targetPath(dir, name string) (string, error) {
path := filepath.Join(dir, name)
if filepath.Dir(path) != filepath.Clean(dir) {
return "", fmt.Errorf("documents: 落盘名 %q: %w", name, ErrBadPath)
}
return path, nil
}
// ExtOf 从原始文件名取出小写扩展名(含点)。它只作**参考**:结果必须同时通过
// 扩展名白名单与字节嗅探,客户端因此无法靠改扩展名换类型。
func ExtOf(filename string) string {
return strings.ToLower(filepath.Ext(filename))
}
// ExtAllowed 报告扩展名是否在放行集合内。deny-by-default:集合之外的一切
// (`.html`/`.svg`/`.xml`/`.zip`/`.docx`/`.xlsx`/`.exe`/`.dll`/`.bat`/`.cmd`/
// `.ps1`/`.js`/`.mjs`/`.sh`/`.py`…)一律拒绝。注意 `.js`/`.py` 即便嗅探成
// `text/plain` 也必须拒绝 —— 判定以扩展名与嗅探的**合取**为准。
//
// 这 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",
PDFExt:
return true
default:
return false
}
}
// 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 {
return hex.EncodeToString(sum)[:HashHexLen] + ext
}