Files
AskaEth 1b8a37ff8c feat(import): 报名模板按「小项 × 组别」分工作表;号码布判重改认证件号;备份改原子落盘
三件事合成一次提交(其中两件都落在 handlers_config.go,无法按文件拆开):

一、分工作表报名模板(新增能力)
- 新增 GET /api/entries/template.xlsx:按当前赛事的「小项 × 组别」生成模板,
  一个小项组别一张工作表,工作表名 = <小项>@<组别>(31 字符上限、重名自动去重)。
- 导入解析器认工作表名还原小项 + 组别;若与表内列冲突则报错,不静默取其一。
- 小项名与组别名禁用 @,基础数据界面 / 规则包导入 / 服务端校验三处一并拦截。
- 单表 CSV/XLSX 导入路径不回归。

二、号码布判重(修误判)
- 旧逻辑依赖证件号本身,而证件号是可选字段 → 同一个人报多个项目会被整块拦下。
- 改为:号码布相同且证件号相同 → 判定为同一人报多项,放行;
  号码布相同且证件号都能填且不同 → 仍报错(真抢号);一方证件号为空 → 放行并给提示。
- 团体行的号码布一并纳入查重。

三、自动备份的关停与落盘(修数据安全隐患)
- export.Service 增加 Wait():StartAuto 的协程进 WaitGroup,关停时 join(10 秒上限,
  超时只记 WARN 不拖死关闭),不再在备份写到一半时退出。
- vacuumInto 改为先写 <最终名>.tmp-<随机> 再 rename,失败则删临时文件;
  Backup / SafetySnapshot / ExportProjectZip 三个调用方一并受益。
- 新增 isFinalBackupName,ListBackups / safetyCopies / KindOfBackupName 统一过滤中间文件,
  避免半截 .db 被列进备份列表、进而被「一键恢复」选中。

文档:docs/ 五份(使用手册 / 报名名单导入规范 / 升级说明 / 部署指南 / 上线检查清单)同步。
2026-09-14 01:10:16 +08:00

709 lines
23 KiB
Go
Raw Permalink 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 export
import (
"archive/zip"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"fmt"
"io"
"io/fs"
"os"
"path"
"path/filepath"
"strings"
"time"
"gopkg.in/yaml.v3"
_ "modernc.org/sqlite"
)
const (
// projectFormat 是赛事项目包的标识,导入时用来挡住「拿错文件」。
projectFormat = "scoreboard-event-project"
// projectFormatVersion 是当前程序能读写的包格式版本。
projectFormatVersion = 1
// eventDBName 是包内赛事库的固定文件名。
eventDBName = "event.db"
)
// maxExtractBytes 限制解压总体积,避免恶意包把磁盘塞满。
// 定义为变量便于测试用较小的上限验证限流逻辑。
var maxExtractBytes int64 = 4 << 30
// ProjectFile 描述包内一个文件的校验信息。
type ProjectFile struct {
Path string `yaml:"path"`
Size int64 `yaml:"size"`
SHA256 string `yaml:"sha256"`
}
// ProjectMeta 是包内 meta.yaml 的映射;导入时先校验 format/formatVersion 与校验和。
type ProjectMeta struct {
Format string `yaml:"format"`
FormatVersion int `yaml:"formatVersion"`
Name string `yaml:"name"`
EventName string `yaml:"eventName,omitempty"`
Subtitle string `yaml:"subtitle,omitempty"`
Organizer string `yaml:"organizer,omitempty"`
CreatedAt time.Time `yaml:"createdAt"`
CreatedBy string `yaml:"createdBy,omitempty"`
AppVersion string `yaml:"appVersion,omitempty"`
Database ProjectFile `yaml:"database"`
Files []ProjectFile `yaml:"files"`
}
// ProjectExportOptions 描述一次赛事包导出。
type ProjectExportOptions struct {
Name string // 项目名,导入后落成 data/event_projs/<Name>/
EventName string // 留空则读赛事库 settings
Subtitle string
Organizer string
CreatedBy string
AppVersion string
UploadsDir string // 可选:背景图 / 会标目录
FontsDir string // 可选:上传字体目录(运行时的 <项目目录>/uploads/fonts)
IncludeFonts bool // 是否把字体一并打包(打进包内 uploads/fonts/)
RulesPath string // 可选:规则包 YAML
Now time.Time
}
// ImportOptions 描述一次赛事包导入。
type ImportOptions struct {
// Overwrite 为 true 时覆盖同名项目;覆盖前旧目录会先改名,避免删到一半失败。
Overwrite bool
// Rename 非空时用该名字作为目录名(解决「两台机器都有同名赛事」)。
Rename string
}
// ImportResult 是导入结果。
type ImportResult struct {
Name string `json:"name"`
Dir string `json:"dir"`
EventDB string `json:"eventDB"`
Meta ProjectMeta `json:"meta"`
}
// ExportProjectZip 把当前赛事库、uploads、可选字体与规则包打成一个 zip。
// 包内 event.db 用 VACUUM INTO 生成,即使服务正在写入也是一致快照。
func ExportProjectZip(db *sql.DB, dst string, opts ProjectExportOptions) (ProjectMeta, error) {
if db == nil {
return ProjectMeta{}, ErrNoDatabase
}
name, err := SafeProjectName(opts.Name)
if err != nil {
return ProjectMeta{}, err
}
meta, _, err := resolveMeta(db, Meta{EventName: opts.EventName, Subtitle: opts.Subtitle, Organizer: opts.Organizer})
if err != nil {
return ProjectMeta{}, err
}
tmp, err := os.MkdirTemp("", "scoreboard-project-*")
if err != nil {
return ProjectMeta{}, fmt.Errorf("export: 创建临时目录: %w", err)
}
defer func() { _ = os.RemoveAll(tmp) }()
if err := vacuumInto(db, filepath.Join(tmp, eventDBName)); err != nil {
return ProjectMeta{}, err
}
// uploads 即使为空也要保留目录,导入方才知道该往哪放资源。
uploads := filepath.Join(tmp, "uploads")
if err := os.MkdirAll(uploads, 0o755); err != nil {
return ProjectMeta{}, fmt.Errorf("export: 创建 uploads 目录: %w", err)
}
if opts.UploadsDir != "" {
if err := copyTree(opts.UploadsDir, uploads); err != nil {
return ProjectMeta{}, err
}
}
if opts.IncludeFonts && opts.FontsDir != "" {
// 字体在包内的位置就是运行时的位置(<项目目录>/uploads/fonts/),
// 否则导入后字体落在没人读的 fonts/ 下,兜底就成了假兜底。
if err := copyTree(opts.FontsDir, filepath.Join(uploads, "fonts")); err != nil {
return ProjectMeta{}, err
}
}
if opts.RulesPath != "" {
if err := copyFile(opts.RulesPath, filepath.Join(tmp, "rules.yaml")); err != nil {
return ProjectMeta{}, err
}
}
files, err := buildManifest(tmp)
if err != nil {
return ProjectMeta{}, err
}
dbFile, ok := findFile(files, eventDBName)
if !ok {
return ProjectMeta{}, fmt.Errorf("%w: 缺少 %s", ErrInvalidPackage, eventDBName)
}
pkg := ProjectMeta{
Format: projectFormat,
FormatVersion: projectFormatVersion,
Name: name,
EventName: meta.EventName,
Subtitle: meta.Subtitle,
Organizer: meta.Organizer,
CreatedAt: nowOr(opts.Now),
CreatedBy: opts.CreatedBy,
AppVersion: opts.AppVersion,
Database: dbFile,
Files: files,
}
raw, err := yaml.Marshal(&pkg)
if err != nil {
return ProjectMeta{}, fmt.Errorf("export: 生成 meta.yaml: %w", err)
}
if err := os.WriteFile(filepath.Join(tmp, "meta.yaml"), raw, 0o644); err != nil {
return ProjectMeta{}, fmt.Errorf("export: 写入 meta.yaml: %w", err)
}
if err := zipDir(tmp, dst); err != nil {
return ProjectMeta{}, err
}
return pkg, nil
}
// InspectProjectZip 只读取包内 meta.yaml 并校验格式,不解压——导入前预览用。
func InspectProjectZip(src string) (ProjectMeta, error) {
r, err := zip.OpenReader(src)
if err != nil {
return ProjectMeta{}, fmt.Errorf("%w: 打开 zip %s: %v", ErrInvalidPackage, src, err)
}
defer func() { _ = r.Close() }()
for _, f := range r.File {
if path.Clean(strings.ReplaceAll(f.Name, "\\", "/")) != "meta.yaml" {
continue
}
rc, err := f.Open()
if err != nil {
return ProjectMeta{}, fmt.Errorf("%w: 读取 meta.yaml: %v", ErrInvalidPackage, err)
}
defer func() { _ = rc.Close() }()
raw, err := io.ReadAll(io.LimitReader(rc, 1<<20))
if err != nil {
return ProjectMeta{}, fmt.Errorf("%w: 读取 meta.yaml: %v", ErrInvalidPackage, err)
}
var meta ProjectMeta
if err := yaml.Unmarshal(raw, &meta); err != nil {
return ProjectMeta{}, fmt.Errorf("%w: 解析 meta.yaml: %v", ErrInvalidPackage, err)
}
if err := validateMeta(meta); err != nil {
return ProjectMeta{}, err
}
return meta, nil
}
return ProjectMeta{}, fmt.Errorf("%w: 缺少 meta.yaml", ErrInvalidPackage)
}
func validateMeta(meta ProjectMeta) error {
if meta.Format != projectFormat {
return fmt.Errorf("%w: format=%q", ErrInvalidPackage, meta.Format)
}
if meta.FormatVersion < 1 || meta.FormatVersion > projectFormatVersion {
return fmt.Errorf("%w: formatVersion=%d(本程序支持 1..%d)", ErrUnsupportedVersion, meta.FormatVersion, projectFormatVersion)
}
if _, err := SafeProjectName(meta.Name); err != nil {
return err
}
if path.Clean(meta.Database.Path) != eventDBName {
return fmt.Errorf("%w: database.path=%q", ErrInvalidPackage, meta.Database.Path)
}
if meta.Database.SHA256 == "" {
return fmt.Errorf("%w: meta.yaml 缺少 event.db 校验和", ErrInvalidPackage)
}
return nil
}
// ImportProjectZip 校验并解压赛事包到 projRoot/<项目名>/,返回落盘位置。
func ImportProjectZip(src, projRoot string, opts ImportOptions) (ImportResult, error) {
meta, err := InspectProjectZip(src)
if err != nil {
return ImportResult{}, err
}
name := meta.Name
if strings.TrimSpace(opts.Rename) != "" {
name = opts.Rename
}
name, err = SafeProjectName(name)
if err != nil {
return ImportResult{}, err
}
if err := os.MkdirAll(projRoot, 0o755); err != nil {
return ImportResult{}, fmt.Errorf("export: 创建赛事项目根目录: %w", err)
}
tmp, err := os.MkdirTemp(projRoot, ".import-*")
if err != nil {
return ImportResult{}, fmt.Errorf("export: 创建导入临时目录: %w", err)
}
defer func() { _ = os.RemoveAll(tmp) }()
if err := extractZip(src, tmp); err != nil {
return ImportResult{}, err
}
if err := verifyManifest(tmp, meta); err != nil {
return ImportResult{}, err
}
eventDB := filepath.Join(tmp, eventDBName)
if err := verifySQLite(eventDB); err != nil {
return ImportResult{}, err
}
target := filepath.Join(projRoot, name)
if err := swapProjectDir(tmp, target, opts.Overwrite, os.Rename); err != nil {
return ImportResult{}, err
}
return ImportResult{
Name: name,
Dir: target,
EventDB: filepath.Join(target, eventDBName),
Meta: meta,
}, nil
}
// swapProjectDir 用 src 目录替换 target 目录。
// 覆盖时先把旧目录改名暂存,src 落盘成功后才删除暂存目录;src 落盘失败则把旧目录改回来,
// 绝不出现「旧项目已删、新项目没成」的空档。rename 注入仅为测试模拟第二次改名失败。
func swapProjectDir(src, target string, overwrite bool, rename func(old, new string) error) error {
name := filepath.Base(target)
stale := ""
if _, err := os.Stat(target); err == nil {
if !overwrite {
return fmt.Errorf("%w: %s", ErrProjectExists, name)
}
stale = target + ".replaced-" + time.Now().Format("20060102-150405")
if err := rename(target, stale); err != nil {
return fmt.Errorf("export: 暂存旧项目 %s: %w", name, err)
}
} else if !os.IsNotExist(err) {
return fmt.Errorf("export: 检查项目目录 %s: %w", target, err)
}
if err := rename(src, target); err != nil {
if stale != "" {
if rbErr := rename(stale, target); rbErr != nil {
return fmt.Errorf("export: 落盘赛事项目 %s: %w(旧项目回滚失败,暂存在 %s: %v)", name, err, stale, rbErr)
}
}
return fmt.Errorf("export: 落盘赛事项目 %s: %w", name, err)
}
if stale != "" {
// 新项目已就位,清理旧目录失败不影响本次导入,留待下次人工处理。
_ = os.RemoveAll(stale)
}
return nil
}
// SafeProjectName 校验项目名:它会被当作目录名,必须挡住路径穿越与 Windows 保留名。
func SafeProjectName(name string) (string, error) {
name = strings.TrimSpace(name)
if name == "" {
return "", fmt.Errorf("%w: 名称为空", ErrUnsafeName)
}
if name == "." || name == ".." {
return "", fmt.Errorf("%w: %q", ErrUnsafeName, name)
}
if len([]rune(name)) > 64 {
return "", fmt.Errorf("%w: 名称超过 64 个字符", ErrUnsafeName)
}
if strings.ContainsAny(name, `/\:*?"<>|`) || strings.ContainsRune(name, 0) {
return "", fmt.Errorf("%w: %q", ErrUnsafeName, name)
}
// Windows 会静默剥掉目录名末尾的点与空格,"赛事." 与 "赛事" 会撞成同一个目录。
if strings.HasSuffix(name, ".") || strings.HasSuffix(name, " ") {
return "", fmt.Errorf("%w: %q 不能以点或空格结尾", ErrUnsafeName, name)
}
for _, r := range name {
if r < 0x20 {
return "", fmt.Errorf("%w: %q", ErrUnsafeName, name)
}
}
if filepath.IsAbs(name) {
return "", fmt.Errorf("%w: %q", ErrUnsafeName, name)
}
base := strings.ToUpper(strings.TrimSuffix(name, filepath.Ext(name)))
switch base {
case "CON", "PRN", "AUX", "NUL",
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9":
return "", fmt.Errorf("%w: %q 是系统保留名", ErrUnsafeName, name)
}
return name, nil
}
// tmpMark 是「中间文件」在文件名里的标记:备份先写成 <最终名>.tmp-<随机>,成功后再改名。
// ListBackups / safetyCopies 靠它把这些文件挡在备份列表之外(见 isFinalBackupName)。
const tmpMark = ".tmp"
// vacuumInto 把 db 的快照写进 dst,并且**只以完整文件的形态出现在 dst 这个名字上**:
// 先写同目录的中间文件 <dst>.tmp-<随机>,VACUUM 成功后再改名过去。
//
// 不能直接写 dst。VACUUM INTO 是「先按最终名建出空文件,再往里灌页」:源库一旦被别的
// 进程/连接锁住(或有别的写入卡着),它会带着一个 0 字节的目标文件在那里等(实测),
// 进程此时退出(正常关闭 / 被 Console 杀掉 / 掉电)就在原地留下半截 .db。
// 备份目录里所有 .db 都会被 ListBackups 列出来、被「一键恢复」当作可恢复的文件选中,
// 而备份是最后一道防线——它自己不能是个坏文件。
//
// 中间文件与最终文件同目录,改名不跨文件系统;失败(含 VACUUM 中途失败)时中间文件会被删掉。
func vacuumInto(db *sql.DB, dst string) error {
tmp, err := vacuumTempPath(dst)
if err != nil {
return err
}
// VACUUM INTO 的目标是语句里的字面量,路径由本包自己生成,转义单引号即可。
stmt := "VACUUM INTO '" + strings.ReplaceAll(tmp, "'", "''") + "'"
if _, err := db.Exec(stmt); err != nil {
// 半截的中间文件既不是备份,也不该在备份目录里攒着——删不掉不算新错误,
// 照原样回报 VACUUM 的失败(它才是操作员要看的原因)。
_ = os.Remove(tmp)
return fmt.Errorf("export: VACUUM INTO %s: %w", dst, err)
}
if err := os.Rename(tmp, dst); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("export: 提交备份文件 %s: %w", dst, err)
}
return nil
}
// vacuumTempPath 给一次 VACUUM INTO 挑一个中间文件名。
//
// 只挑名字、不建文件:VACUUM INTO 拒绝写已经存在的目标文件。随机串用来避开
// 「上一次异常退出留下的同名中间文件」与「并发的 CLI 备份(scoreboard backup)」。
func vacuumTempPath(dst string) (string, error) {
dir, base := filepath.Split(dst)
var buf [8]byte
if _, err := rand.Read(buf[:]); err != nil {
return "", fmt.Errorf("export: 生成备份中间文件名: %w", err)
}
p := filepath.Join(dir, base+tmpMark+"-"+hex.EncodeToString(buf[:]))
if _, err := os.Stat(p); err == nil {
return "", fmt.Errorf("export: 备份中间文件名 %s 已存在,请清理后重试", p)
} else if !os.IsNotExist(err) {
return "", fmt.Errorf("export: 检查备份中间文件 %s: %w", p, err)
}
return p, nil
}
func buildManifest(root string) ([]ProjectFile, error) {
var out []ProjectFile
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
rel, err := filepath.Rel(root, p)
if err != nil {
return err
}
rel = filepath.ToSlash(rel)
if rel == "meta.yaml" {
return nil
}
sum, size, err := hashFile(p)
if err != nil {
return err
}
out = append(out, ProjectFile{Path: rel, Size: size, SHA256: sum})
return nil
})
if err != nil {
return nil, fmt.Errorf("export: 生成文件清单: %w", err)
}
return out, nil
}
func findFile(files []ProjectFile, name string) (ProjectFile, bool) {
for _, f := range files {
if f.Path == name {
return f, true
}
}
return ProjectFile{}, false
}
func hashFile(p string) (string, int64, error) {
f, err := os.Open(p)
if err != nil {
return "", 0, fmt.Errorf("export: 读取 %s: %w", p, err)
}
defer func() { _ = f.Close() }()
h := sha256.New()
n, err := io.Copy(h, f)
if err != nil {
return "", 0, fmt.Errorf("export: 计算 %s 校验和: %w", p, err)
}
return hex.EncodeToString(h.Sum(nil)), n, nil
}
func copyTree(src, dst string) error {
info, err := os.Stat(src)
if err != nil {
return fmt.Errorf("export: 读取目录 %s: %w", src, err)
}
if !info.IsDir() {
return fmt.Errorf("export: %s 不是目录", src)
}
if err := os.MkdirAll(dst, 0o755); err != nil {
return fmt.Errorf("export: 创建目录 %s: %w", dst, err)
}
return filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(src, p)
if err != nil {
return err
}
if rel == "." {
return nil
}
target := filepath.Join(dst, rel)
if d.IsDir() {
return os.MkdirAll(target, 0o755)
}
return copyFile(p, target)
})
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return fmt.Errorf("export: 读取 %s: %w", src, err)
}
defer func() { _ = in.Close() }()
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return fmt.Errorf("export: 创建目录 %s: %w", filepath.Dir(dst), err)
}
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
return fmt.Errorf("export: 创建 %s: %w", dst, err)
}
if _, err := io.Copy(out, in); err != nil {
_ = out.Close()
return fmt.Errorf("export: 复制 %s → %s: %w", src, dst, err)
}
if err := out.Close(); err != nil {
return fmt.Errorf("export: 关闭 %s: %w", dst, err)
}
return nil
}
// zipDir 先写临时文件再改名,避免导出中途失败留下半截 zip。
func zipDir(root, dst string) error {
if dir := filepath.Dir(dst); dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("export: 创建导出目录 %s: %w", dir, err)
}
}
tmp := dst + ".tmp"
out, err := os.Create(tmp)
if err != nil {
return fmt.Errorf("export: 创建 %s: %w", tmp, err)
}
zw := zip.NewWriter(out)
if err := writeZipTree(zw, root); err != nil {
_ = zw.Close()
_ = out.Close()
_ = os.Remove(tmp)
return err
}
if err := zw.Close(); err != nil {
_ = out.Close()
_ = os.Remove(tmp)
return fmt.Errorf("export: 关闭 zip: %w", err)
}
if err := out.Close(); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("export: 关闭 %s: %w", tmp, err)
}
if err := os.Rename(tmp, dst); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("export: 重命名 %s: %w", dst, err)
}
return nil
}
func writeZipTree(zw *zip.Writer, root string) error {
return filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(root, p)
if err != nil {
return err
}
if rel == "." {
return nil
}
name := filepath.ToSlash(rel)
if d.IsDir() {
// 保留空目录(如空的 uploads/),导入方才能得到完整结构。
_, err := zw.Create(name + "/")
return err
}
info, err := d.Info()
if err != nil {
return err
}
hdr, err := zip.FileInfoHeader(info)
if err != nil {
return err
}
hdr.Name = name
hdr.Method = zip.Deflate
w, err := zw.CreateHeader(hdr)
if err != nil {
return err
}
src, err := os.Open(p)
if err != nil {
return fmt.Errorf("export: 读取 %s: %w", p, err)
}
_, copyErr := io.Copy(w, src)
closeErr := src.Close()
if copyErr != nil {
return fmt.Errorf("export: 写入 zip 条目 %s: %w", name, copyErr)
}
return closeErr
})
}
func extractZip(src, dst string) error {
r, err := zip.OpenReader(src)
if err != nil {
return fmt.Errorf("%w: 打开 zip %s: %v", ErrInvalidPackage, src, err)
}
defer func() { _ = r.Close() }()
var total int64
for _, f := range r.File {
target, err := secureJoin(dst, f.Name)
if err != nil {
return err
}
if f.FileInfo().IsDir() {
if err := os.MkdirAll(target, 0o755); err != nil {
return fmt.Errorf("export: 创建目录 %s: %w", target, err)
}
continue
}
// 声明值先卡一道,避免一上来就落盘;注意先判上界再转 int64,防止溢出。
if f.UncompressedSize64 > uint64(maxExtractBytes) || total+int64(f.UncompressedSize64) > maxExtractBytes {
return fmt.Errorf("%w: 解压体积超过 %d 字节", ErrInvalidPackage, maxExtractBytes)
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return fmt.Errorf("export: 创建目录 %s: %w", filepath.Dir(target), err)
}
rc, err := f.Open()
if err != nil {
return fmt.Errorf("%w: 读取条目 %s: %v", ErrInvalidPackage, f.Name, err)
}
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
_ = rc.Close()
return fmt.Errorf("export: 写入 %s: %w", target, err)
}
// header 里的声明值可以伪造,按实际写出的字节数再卡一次上限。
n, copyErr := io.Copy(out, io.LimitReader(rc, maxExtractBytes-total+1))
closeOutErr := out.Close()
closeInErr := rc.Close()
if copyErr != nil {
return fmt.Errorf("export: 解压 %s: %w", f.Name, copyErr)
}
total += n
if total > maxExtractBytes {
return fmt.Errorf("%w: 解压体积超过 %d 字节", ErrInvalidPackage, maxExtractBytes)
}
if closeOutErr != nil {
return fmt.Errorf("export: 关闭 %s: %w", target, closeOutErr)
}
if closeInErr != nil {
return fmt.Errorf("export: 关闭条目 %s: %w", f.Name, closeInErr)
}
}
return nil
}
// secureJoin 把 zip 条目名限制在 root 之内,挡住 ../ 与绝对路径(zip slip)。
func secureJoin(root, name string) (string, error) {
clean := path.Clean(strings.ReplaceAll(name, "\\", "/"))
if clean == "." {
return root, nil
}
if path.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, "../") {
return "", fmt.Errorf("%w: 非法路径 %q", ErrInvalidPackage, name)
}
target := filepath.Join(root, filepath.FromSlash(clean))
rel, err := filepath.Rel(root, target)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("%w: 非法路径 %q", ErrInvalidPackage, name)
}
return target, nil
}
func verifyManifest(root string, meta ProjectMeta) error {
if len(meta.Files) == 0 {
return fmt.Errorf("%w: meta.yaml 缺少文件清单", ErrInvalidPackage)
}
for _, f := range meta.Files {
target, err := secureJoin(root, f.Path)
if err != nil {
return err
}
sum, size, err := hashFile(target)
if err != nil {
return fmt.Errorf("%w: %v", ErrInvalidPackage, err)
}
if f.Size != 0 && f.Size != size {
return fmt.Errorf("%w: %s 大小 %d ≠ %d", ErrChecksumMismatch, f.Path, size, f.Size)
}
if !strings.EqualFold(sum, f.SHA256) {
return fmt.Errorf("%w: %s", ErrChecksumMismatch, f.Path)
}
}
dbFile, ok := findFile(meta.Files, eventDBName)
if !ok {
return fmt.Errorf("%w: 文件清单缺少 %s", ErrInvalidPackage, eventDBName)
}
if !strings.EqualFold(dbFile.SHA256, meta.Database.SHA256) {
return fmt.Errorf("%w: database.sha256 与清单不一致", ErrChecksumMismatch)
}
return nil
}
// verifySQLite 确认包内 event.db 确实是完整可读的赛事库,而不是改了后缀的其它文件。
func verifySQLite(p string) error {
db, err := sql.Open("sqlite", "file:"+p+"?mode=ro")
if err != nil {
return fmt.Errorf("%w: 打开 %s: %v", ErrInvalidPackage, p, err)
}
defer func() { _ = db.Close() }()
var integrity string
if err := db.QueryRow(`PRAGMA integrity_check`).Scan(&integrity); err != nil {
return fmt.Errorf("%w: 完整性检查: %v", ErrInvalidPackage, err)
}
if !strings.EqualFold(integrity, "ok") {
return fmt.Errorf("%w: 完整性检查结果 %q", ErrInvalidPackage, integrity)
}
var n int
if err := db.QueryRow(`SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = 'schema_migrations'`).Scan(&n); err != nil {
return fmt.Errorf("%w: 读取表结构: %v", ErrInvalidPackage, err)
}
if n == 0 {
return fmt.Errorf("%w: %s 不是赛事库(缺少 schema_migrations)", ErrInvalidPackage, eventDBName)
}
return nil
}