Files
artex/server/skill_upload_test.go
T
dela 0335d572de
ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
detections / detections (push) Waiting to run
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
First Commit
2026-10-09 08:38:16 +08:00

243 lines
8.0 KiB
Go

package server
import (
"archive/zip"
"bytes"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/klauspost/compress/zstd"
"golang.org/x/text/encoding/simplifiedchinese"
)
func TestValidSkillName(t *testing.T) {
ok := []string{"web-recon", "a", "nuclei2", "中文技能", "端口扫描-x", "日本語スキル"}
bad := []string{
"", "Web-Recon", "-lead", "trail-", "dou--ble", "has space", "中文 技能",
"dot.name", "a/b", `a\b`, "..", ".", "中文/技能", "sk\x00ill", "中‮文",
strings.Repeat("a", 65), "1abc", "中文技能!",
}
for _, n := range ok {
if !validSkillName(n) {
t.Errorf("validSkillName(%q) = false, want true", n)
}
}
for _, n := range bad {
if validSkillName(n) {
t.Errorf("validSkillName(%q) = true, want false", n)
}
}
}
func TestSkillRelPath(t *testing.T) {
ok := map[string]string{
"SKILL.md": "SKILL.md",
"scripts/run.py": "scripts/run.py",
"参考/中文说明.md": "参考/中文说明.md",
"references/a b.txt": "references/a b.txt",
"./SKILL.md": "SKILL.md",
"assets/图片-1_v2.png": "assets/图片-1_v2.png",
}
for in, want := range ok {
got, msg := skillRelPath(in)
if msg != "" || got != want {
t.Errorf("skillRelPath(%q) = (%q, %q), want (%q, \"\")", in, got, msg, want)
}
}
bad := []string{
"", "../etc/passwd", "a/../../b", "/abs/path", "a//b", `..\..\x`,
"%2e%2e/x", "a\x00b", "中文 名.md", "中‮文.md", "a#b.md", "a?b.md",
"a:b.md", string([]byte{0xd6, 0xd0}) + ".md", // 裸 GBK 字节:非法 UTF-8
strings.Repeat("a", maxSkillPathLen+1),
}
for _, in := range bad {
if got, msg := skillRelPath(in); msg == "" {
t.Errorf("skillRelPath(%q) = (%q, \"\"), want rejection", in, got)
}
}
}
type nopWriteCloser struct{ io.Writer }
func (nopWriteCloser) Close() error { return nil }
// zipFile describes one entry for buildZip.
type zipFile struct {
name string
body string
method uint16
nonUTF8 bool // write the name bytes as-is (GBK 包)
}
func buildZip(t *testing.T, files ...zipFile) []byte {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
zw.RegisterCompressor(zipMethodZstd, zstd.ZipCompressor())
// Deflate64 没有纯 Go 编码器;这里原样写入,只是为了给条目打上 method 9 的标记 ——
// 断言的是「方法不支持时给什么提示」,不会真去解压它。
zw.RegisterCompressor(zipMethodDeflate64, func(w io.Writer) (io.WriteCloser, error) {
return nopWriteCloser{w}, nil
})
for _, f := range files {
h := &zip.FileHeader{Name: f.name, Method: f.method, NonUTF8: f.nonUTF8}
w, err := zw.CreateHeader(h)
if err != nil {
t.Fatalf("CreateHeader(%q): %v", f.name, err)
}
if _, err := io.WriteString(w, f.body); err != nil {
t.Fatalf("write %q: %v", f.name, err)
}
}
if err := zw.Close(); err != nil {
t.Fatalf("zip close: %v", err)
}
return buf.Bytes()
}
// uploadZip posts raw zip bytes to fsUploadSkill and returns the response.
func uploadZip(t *testing.T, skillDir string, filename string, data []byte) (*httptest.ResponseRecorder, map[string]any) {
t.Helper()
var body bytes.Buffer
mw := multipart.NewWriter(&body)
fw, err := mw.CreateFormFile("file", filename)
if err != nil {
t.Fatal(err)
}
if _, err := fw.Write(data); err != nil {
t.Fatal(err)
}
mw.Close()
req := httptest.NewRequest(http.MethodPost, "/api/skills/upload", &body)
req.Header.Set("Content-Type", mw.FormDataContentType())
rr := httptest.NewRecorder()
(&Server{skillDir: skillDir}).fsUploadSkill(rr, req)
var out map[string]any
_ = json.Unmarshal(rr.Body.Bytes(), &out)
return rr, out
}
const zhSkillMD = "---\nname: 中文技能\ndescription: 测试\n---\n正文\n"
// A zstd-compressed archive (WinZip 的可选压缩方式) used to blow up with
// "zip: unsupported compression"; it now installs like any Deflate archive.
func TestUploadSkillZstdAndChineseNames(t *testing.T) {
dir := t.TempDir()
data := buildZip(t,
zipFile{name: "中文技能/SKILL.md", body: zhSkillMD, method: zipMethodZstd},
zipFile{name: "中文技能/参考/说明 文档.md", body: "参考", method: zipMethodZstd},
zipFile{name: "中文技能/scripts/run.py", body: "print(1)", method: zip.Deflate},
)
rr, out := uploadZip(t, dir, "中文技能.zip", data)
if rr.Code != 201 {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body)
}
if out["name"] != "中文技能" {
t.Fatalf("name = %v, want 中文技能", out["name"])
}
for _, rel := range []string{"SKILL.md", "参考/说明 文档.md", "scripts/run.py"} {
if _, err := os.Stat(filepath.Join(dir, "中文技能", rel)); err != nil {
t.Errorf("missing extracted file %q: %v", rel, err)
}
}
}
// GBK-named entries (7-Zip / 资源管理器 on Chinese Windows) must be decoded rather
// than rejected as invalid UTF-8 paths.
func TestUploadSkillGBKNames(t *testing.T) {
gbk := func(s string) string {
b, err := simplifiedchinese.GBK.NewEncoder().String(s)
if err != nil {
t.Fatalf("gbk encode %q: %v", s, err)
}
return b
}
dir := t.TempDir()
data := buildZip(t,
zipFile{name: gbk("中文技能/SKILL.md"), body: zhSkillMD, method: zip.Deflate, nonUTF8: true},
zipFile{name: gbk("中文技能/参考资料.md"), body: "内容", method: zip.Deflate, nonUTF8: true},
)
rr, out := uploadZip(t, dir, "skill.zip", data)
if rr.Code != 201 {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body)
}
if out["name"] != "中文技能" {
t.Fatalf("name = %v, want 中文技能", out["name"])
}
if _, err := os.Stat(filepath.Join(dir, "中文技能", "参考资料.md")); err != nil {
t.Errorf("GBK-named entry not extracted: %v", err)
}
}
// An archive we genuinely cannot decode should name the method in Korean instead of
// surfacing "zip: unsupported compression algorithm".
func TestUploadSkillUnsupportedMethod(t *testing.T) {
data := buildZip(t,
zipFile{name: "demo/SKILL.md", body: "---\nname: demo\n---\n", method: zipMethodDeflate64},
)
rr, out := uploadZip(t, t.TempDir(), "demo.zip", data)
if rr.Code != 400 {
t.Fatalf("status = %d, want 400 (body %s)", rr.Code, rr.Body)
}
msg, _ := out["error"].(string)
if !strings.Contains(msg, "Deflate64") || !strings.Contains(msg, "지원하지 않는 압축 방식") {
t.Fatalf("error = %q, want a Korean message naming Deflate64", msg)
}
}
func TestUploadSkillEncrypted(t *testing.T) {
data := buildZip(t, zipFile{name: "demo/SKILL.md", body: "---\nname: demo\n---\n", method: zip.Deflate})
// flip the "encrypted" general-purpose flag bit in the local file header
// (offset 6) and in the central directory copy (offset 8).
local := bytes.Index(data, []byte("PK\x03\x04"))
central := bytes.Index(data, []byte("PK\x01\x02"))
if local < 0 || central < 0 {
t.Fatal("could not locate zip headers")
}
data[local+6] |= 1
data[central+8] |= 1
rr, out := uploadZip(t, t.TempDir(), "demo.zip", data)
if rr.Code != 400 {
t.Fatalf("status = %d, want 400 (body %s)", rr.Code, rr.Body)
}
if msg, _ := out["error"].(string); !strings.Contains(msg, "암호화") {
t.Fatalf("error = %q, want 암호화 hint", msg)
}
}
// Zip-slip must still be refused now that the path check accepts Unicode.
func TestUploadSkillRejectsTraversal(t *testing.T) {
data := buildZip(t,
zipFile{name: "demo/SKILL.md", body: "---\nname: demo\n---\n", method: zip.Deflate},
zipFile{name: "demo/../../evil.sh", body: "rm -rf /", method: zip.Deflate},
)
dir := t.TempDir()
rr, out := uploadZip(t, dir, "demo.zip", data)
if rr.Code != 400 {
t.Fatalf("status = %d, want 400 (body %s)", rr.Code, rr.Body)
}
if msg, _ := out["error"].(string); !strings.Contains(msg, "잘못된 경로") {
t.Fatalf("error = %q, want 잘못된 경로", msg)
}
if entries, _ := os.ReadDir(dir); len(entries) != 0 {
t.Fatalf("upload left files behind: %v", entries)
}
}
func TestSkillNameFromFrontmatterQuoted(t *testing.T) {
got := skillNameFromFrontmatter([]byte("---\nname: \"中文技能\"\ndescription: x\n---\n"))
if got != "中文技能" {
t.Fatalf("name = %q, want 中文技能", got)
}
}