Files
Autumn-27 338e77c064 fix(skills): 修复技能压缩包上传失败与中文命名被判非法
- 补上 bzip2 / Zstandard 解压器(标准库 + 已有的 klauspost/compress,不引新依赖),
  不再对压缩软件非默认档位打出的包抛 "zip: unsupported compression";
  Deflate64/LZMA/XZ/PPMd 等真解不了的方式与加密包改为解压前给出中文提示,
  点名文件、压缩方式与重新打包的办法
- 技能内文件路径校验从 [A-Za-z0-9-_./] 的 ASCII 白名单改为 Unicode 黑名单:
  放行各语言文件名与空格,仍拒绝控制字符、非法 UTF-8、零宽与双向控制字符、
  \ % # ? * : " < > | 及 .. / 绝对路径 / 空路径段,防目录穿越行为不变
- 技能名放开非 ASCII 字母数字(中文可直接作技能名),ASCII 仍守 agentskills.io
  规范,空格、点与路径分隔符照旧拒绝
- Windows 压缩软件按 GBK 写的文件名兜底解码后再校验;frontmatter 的
  name: "中文技能" 去引号
- 新增 8 个测试:zstd 包、GBK 包、不支持的压缩方式、加密包、目录穿越、名称与
  路径校验
2026-08-31 11:37:44 -04:00

131 lines
4.2 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 server
import (
"archive/zip"
"bytes"
"compress/bzip2"
"fmt"
"io"
"path"
"strings"
"unicode/utf8"
"github.com/klauspost/compress/zstd"
"golang.org/x/text/encoding/simplifiedchinese"
)
// Go 的 archive/zip 只内置 Store(0) 和 Deflate(8) 两种解压器,遇到别的方法会返回
// "zip: unsupported compression algorithm"。压缩软件在非默认档位下经常写出别的方法
// (7-Zip 的 bzip2、WinZip 的 zstd),所以这里把纯 Go 能解的两种补上;真的解不了的
// (Deflate64 / LZMA / XZ / PPMd / 加密包) 在解压前就报出中文提示,而不是把底层
// 错误原样甩给用户。
const (
zipMethodStore = 0
zipMethodDeflate = 8
zipMethodDeflate64 = 9
zipMethodBzip2 = 12
zipMethodLZMA = 14
zipMethodZstdPKW = 20 // PKWARE 早期给 zstd 分配的编号
zipMethodZstd = 93
zipMethodXZ = 95
zipMethodJPEG = 96
zipMethodWavPack = 97
zipMethodPPMd = 98
zipMethodAES = 99
)
var zipMethodNames = map[uint16]string{
zipMethodStore: "Store",
zipMethodDeflate: "Deflate",
zipMethodDeflate64: "Deflate64",
zipMethodBzip2: "bzip2",
zipMethodLZMA: "LZMA",
zipMethodZstdPKW: "Zstandard",
zipMethodZstd: "Zstandard",
zipMethodXZ: "XZ",
zipMethodJPEG: "JPEG",
zipMethodWavPack: "WavPack",
zipMethodPPMd: "PPMd",
zipMethodAES: "AES 加密",
}
func zipMethodName(m uint16) string {
if n, ok := zipMethodNames[m]; ok {
return n
}
return "未知"
}
// newSkillZipReader parses an uploaded archive and registers the extra decompressors
// we can support beyond the stdlib's Store/Deflate.
func newSkillZipReader(buf []byte) (*zip.Reader, error) {
zr, err := zip.NewReader(bytes.NewReader(buf), int64(len(buf)))
if err != nil {
return nil, fmt.Errorf("无法解析压缩包(需为 zip 格式):%w", err)
}
zr.RegisterDecompressor(zipMethodBzip2, func(r io.Reader) io.ReadCloser {
return io.NopCloser(bzip2.NewReader(r))
})
zdec := zstd.ZipDecompressor(zstd.WithDecoderConcurrency(1))
zr.RegisterDecompressor(zipMethodZstd, zdec)
zr.RegisterDecompressor(zipMethodZstdPKW, zdec)
return zr, nil
}
// skillZipEntry pairs a zip entry with its decoded (UTF-8) name — f.Name may hold
// raw GBK bytes, see zipEntryName.
type skillZipEntry struct {
f *zip.File
name string
}
// skillZipEntries lists the archive's real files (no directory entries, no archiver
// junk) with their names decoded to UTF-8.
func skillZipEntries(zr *zip.Reader) []skillZipEntry {
out := make([]skillZipEntry, 0, len(zr.File))
for _, f := range zr.File {
if f.FileInfo().IsDir() {
continue
}
name := zipEntryName(f)
if strings.HasPrefix(name, "__MACOSX/") || strings.Contains(name, "/__MACOSX/") ||
path.Base(name) == ".DS_Store" {
continue // macOS 打包残留
}
out = append(out, skillZipEntry{f: f, name: name})
}
return out
}
// zipEntryName returns the entry path as UTF-8. Windows 上的 7-Zip / WinRAR / 资源管理器
// 在不置 UTF-8 标志位时会把中文文件名按 GBK 写进 zip,Go 原样保留这些字节,于是名字
// 既不是合法 UTF-8 也过不了路径校验 —— 这里按 GBK 兜底解码。
func zipEntryName(f *zip.File) string {
if utf8.ValidString(f.Name) {
return f.Name
}
if dec, err := simplifiedchinese.GBK.NewDecoder().String(f.Name); err == nil && utf8.ValidString(dec) {
return dec
}
return f.Name
}
// checkSkillZipMethods rejects archives we cannot extract, naming the offending
// entry and method instead of letting f.Open() fail with an opaque English error.
func checkSkillZipMethods(entries []skillZipEntry) error {
for _, e := range entries {
if e.f.Flags&0x1 != 0 || e.f.Method == zipMethodAES {
return fmt.Errorf("压缩包已加密(%s),请上传未加密的 zip", e.name)
}
switch e.f.Method {
case zipMethodStore, zipMethodDeflate, zipMethodBzip2, zipMethodZstd, zipMethodZstdPKW:
default:
return fmt.Errorf("压缩包使用了不支持的压缩方式 %s(method %d):%s。"+
"请改用「存储」或「Deflate」重新打包(7-Zip/WinRAR 的压缩方式选 Deflate,"+
"或直接用系统自带的“压缩/发送到压缩文件夹”、命令行 zip -r)",
zipMethodName(e.f.Method), e.f.Method, e.name)
}
}
return nil
}