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

135 lines
6.3 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
import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
)
// 本文件是 sidecar 的**读侧出口**(design/24 §4.2 路线②)。装配期的
// `resolveDocuments` 走 `sidecarStore.load`(未命中就重抽一次),而
// `document-read` 工具要的是「已经落盘的那份正文」:它既不该重抽(design/24 §4.3
// 「只抽一次」,否则 status/chars 的持久统计会漂移),也不该把 sidecar 形状
// (首行元数据 + 正文自第二行起)再实现一遍。这里因此把读路径收成一个小出口,
// 形状、名字形态校验、抽取器版本判据仍**只有一份实现**。
// ErrSidecarUnavailable 报告一份文档**没有可读的抽取结果**:数据目录未配置、
// sidecar 不存在、读不出、形态损坏或抽取器版本不匹配。不重抽的读侧据此给出诚实
// 错误,绝不返回空正文假装成功(design/24 §4.4)。一律用 errors.Is 判定。
var ErrSidecarUnavailable = errors.New("没有可读的抽取结果")
// Sidecar 是一条已落盘 sidecar 的读取结果 —— 纯读取,不含任何抽取动作。
type Sidecar struct {
// Status 是抽取结论,取值见 history.DocumentStatus*。
Status string
// Reason 是 Status == unreadable 时的诚实原因(正文位置为空)。
Reason string
// Kind 是抽取所得内容的种类(text / csv / tsv / pdf)。
Kind string
// Pages 是 PDF 的页数(其它种类为 0)。
Pages int
// Chars 是抽取期写盘时的正文字符数(rune)。
Chars int
// Truncated 报告 sidecar 正文在**抽取期**是否撞上了安全上限
// (documents.MaxSidecarBytes,8 MiB 文本)—— 它与「本回合注入被截断」是两件事:
// 后者由装配期按注入预算判定并写进 history.DocumentRef。
Truncated bool
// Text 是正文本身:sidecar 第一行元数据**之后**的部分(含 CSV/TSV 的摘要行)。
Text string
}
// Readable 报告这条记录是否带着可读正文:只有抽取结论为 extracted 才有正文
// (unreadable 时 sidecar 的正文位置是空的,原因在 Reason 里)。
func (s Sidecar) Readable() bool { return s.Status == history.DocumentStatusExtracted }
// ReadSidecar 读取 name 的 sidecar:**只读 sidecar,绝不重抽、绝不读原文件**。
//
// 名字形态非法(非单个路径段、非 32 位小写十六进制主名、扩展名超长)返回包装
// ErrInvalidName 的错误,并且**在触达文件系统之前**就拒绝;其余失败(数据目录
// 未配置、sidecar 不存在、读不出、损坏、版本失效)返回包装 ErrSidecarUnavailable
// 的错误,具体原因在错误链的文案里。dataDir 是**数据目录**(不是 extracted 目录):
// sidecar 落在它的 extracted/ 子目录下,与装配期解析器同一口径。
func ReadSidecar(dataDir, name string) (Sidecar, error) {
if !validStorageName(name) {
return Sidecar{}, fmt.Errorf("documents: read sidecar %q: %w", name, ErrInvalidName)
}
store := newSidecarStore(dataDir, sourcedLog(nil))
if store == nil {
return Sidecar{}, fmt.Errorf("documents: read sidecar %q: 数据目录未配置: %w", name, ErrSidecarUnavailable)
}
rec, err := store.loadSidecar(name)
if err != nil {
return Sidecar{}, fmt.Errorf("documents: read sidecar %q: %w", name, err)
}
return Sidecar{
Status: rec.Status,
Reason: rec.Reason,
Kind: rec.Kind,
Pages: rec.Pages,
Chars: rec.Chars,
Truncated: rec.Truncated,
Text: rec.Text,
}, nil
}
// loadSidecar 是 sidecar 读路径的唯一实现。命中返回记录与 nil;任何失败返回包装了
// ErrSidecarUnavailable 的错误(名字形态非法时为 ErrInvalidName),并把**具体原因**
// (不存在 / 读不出 / 形态损坏 / 版本失效)写进错误链 —— 装配期的 load 只关心「是否
// 命中」(未命中就重抽一次),而不重抽的读侧需要知道到底为什么读不到。
//
// 日志口径与既有读路径一致:缓存未命中是正常路径,「文件不在」因此不吵;其余失败
// 留一条带来源的 WARN/INFO,绝不静默吞掉「文件在那儿却读不出来」这类信号(日志只记
// name/字节数/版本,**绝不记正文**)。
func (s *sidecarStore) loadSidecar(name string) (cachedExtract, error) {
if s == nil {
return cachedExtract{}, fmt.Errorf("sidecar 存储未配置: %w", ErrSidecarUnavailable)
}
path, ok := s.pathFor(name)
if !ok {
return cachedExtract{}, fmt.Errorf("sidecar 名字形态非法: %w", ErrInvalidName)
}
f, err := os.Open(path)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
s.log.Warn("sidecar open", "name", name, "error", err)
return cachedExtract{}, fmt.Errorf("sidecar 打开失败: %w", ErrSidecarUnavailable)
}
return cachedExtract{}, fmt.Errorf("sidecar 不存在: %w", ErrSidecarUnavailable)
}
defer func() { _ = f.Close() }()
data, err := io.ReadAll(io.LimitReader(f, maxSidecarBytes+1))
if err != nil || len(data) > maxSidecarBytes {
s.log.Warn("sidecar unreadable", "name", name, "bytes", len(data), "error", err)
return cachedExtract{}, fmt.Errorf("sidecar 读不出或超出上限(%d 字节): %w", len(data), ErrSidecarUnavailable)
}
header, body, ok := strings.Cut(string(data), sidecarHeaderEnd)
if !ok || !strings.HasPrefix(header, "{") {
s.log.Warn("sidecar malformed", "name", name)
return cachedExtract{}, fmt.Errorf("sidecar 形态损坏(首行不是元数据): %w", ErrSidecarUnavailable)
}
meta := sidecarMeta{}
if err := json.Unmarshal([]byte(header), &meta); err != nil {
s.log.Warn("sidecar meta decode", "name", name, "error", err)
return cachedExtract{}, fmt.Errorf("sidecar 元数据解码失败: %w", ErrSidecarUnavailable)
}
if meta.Version != ExtractorVersion {
// 抽取器升级:旧缓存按失效处理并重抽一次(记版本号的意义所在)。
s.log.Info("sidecar stale", "name", name, "version", meta.Version)
return cachedExtract{}, fmt.Errorf("sidecar 抽取器版本不匹配(%s): %w", meta.Version, ErrSidecarUnavailable)
}
return cachedExtract{
Status: meta.Status,
Reason: meta.Reason,
Kind: meta.Kind,
Pages: meta.Pages,
Chars: meta.Chars,
Truncated: meta.Truncated,
Text: body,
}, nil
}