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