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