DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
commit 645b64fe21
4388 files changed
+1405457

No files matched your search

+453
View File
@@ -0,0 +1,453 @@
package mod
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"io/fs"
"os"
"path"
"path/filepath"
"regexp"
"strings"
"unicode/utf8"
)
const CN115Format = "115CN-mod"
var modIDPattern = regexp.MustCompile(`^[a-z0-9_-]{1,64}$`)
var resourceHashPattern = regexp.MustCompile(`^[a-f0-9]{64}$`)
func ValidateID(id string) error {
if !modIDPattern.MatchString(id) {
return fmt.Errorf("MOD 和组件 ID 仅支持小写字母、数字、-、_,长度为 1–64")
}
return nil
}
type cn115Manifest struct {
Schema int `json:"schema"`
Client string `json:"client"`
Kind string `json:"kind"`
ID string `json:"id"`
Name string `json:"name"`
Version string `json:"version"`
Author string `json:"author"`
Description string `json:"description"`
Components []Component `json:"components"`
Requires []string `json:"requires,omitempty"`
Conflicts []string `json:"conflicts,omitempty"`
Files []cn115Resource `json:"files"`
PVF []cn115Operation `json:"pvf"`
}
type cn115Resource struct {
Component string `json:"component"`
Source string `json:"source"`
Target string `json:"target"`
Size *int64 `json:"size"`
SHA256 string `json:"sha256"`
}
type cn115Operation struct {
Component string `json:"component"`
Source string `json:"source"`
Path string `json:"path"`
Mode string `json:"mode"`
Type int32 `json:"type,omitempty"`
}
type CN115Reader struct{}
type CN115Writer struct{}
func cn115Root(root fs.FS) (fs.FS, error) {
entries, err := fs.ReadDir(root, ".")
if err != nil {
return nil, err
}
for _, e := range entries {
if e.Name() == "mod.json" {
if !e.Type().IsRegular() {
return nil, fmt.Errorf("mod.json 必须是普通文件")
}
return root, nil
}
}
if len(entries) == 1 && entries[0].IsDir() {
sub, err := fs.Sub(root, entries[0].Name())
if err == nil {
nested, readErr := fs.ReadDir(sub, ".")
if readErr != nil {
return nil, readErr
}
for _, e := range nested {
if e.Name() == "mod.json" && e.Type().IsRegular() {
return sub, nil
}
}
}
}
return nil, fmt.Errorf("根目录或唯一的一层包目录内缺少 mod.json")
}
func (CN115Reader) Detect(root fs.FS) (bool, error) {
_, err := cn115Root(root)
if err != nil {
return false, err
}
return true, nil
}
func (CN115Reader) Read(root fs.FS) (Package, error) {
var pkg Package
root, err := cn115Root(root)
if err != nil {
return pkg, err
}
data, err := readPackageFile(root, "mod.json", 4<<20)
if err != nil {
return pkg, err
}
var manifest cn115Manifest
if !utf8.Valid(data) {
return pkg, fmt.Errorf("mod.json 必须使用 UTF-8")
}
if err := json.Unmarshal(data, &manifest); err != nil {
return pkg, fmt.Errorf("解析 mod.json: %w", err)
}
if manifest.Schema != 1 || manifest.Client != "115CN" || manifest.Kind != "resource" {
return pkg, fmt.Errorf("只支持 schema=1、client=115CN、kind=resource 的 MOD")
}
if err := ValidateID(manifest.ID); err != nil {
return pkg, err
}
if strings.TrimSpace(manifest.Name) == "" {
return pkg, fmt.Errorf("MOD 名称不能为空")
}
if err := ValidateVersion(manifest.Version); err != nil {
return pkg, err
}
pkg.Metadata = Metadata{ID: manifest.ID, Name: manifest.Name, Version: manifest.Version}
pkg.Components = manifest.Components
if err := validateComponents(pkg.Components); err != nil {
return pkg, err
}
components := map[string]bool{}
for _, c := range pkg.Components {
components[c.ID] = true
}
for _, id := range append(append([]string{}, manifest.Requires...), manifest.Conflicts...) {
if err := ValidateID(id); err != nil {
return pkg, err
}
if id == manifest.ID {
return pkg, fmt.Errorf("MOD 不能依赖自身或与自身冲突")
}
}
if len(manifest.Requires)+len(manifest.Conflicts) > 0 {
pkg.Warnings = append(pkg.Warnings, "包级依赖/冲突需在登录器中核对;本工具仅将所选 PVF 内容导入当前归档,不管理 MOD 安装状态")
}
for _, r := range manifest.Files {
if !components[r.Component] || ValidatePath(r.Source) != nil || protectedPackagePath(r.Source) || !validResourceTarget(r.Target) || r.Size == nil || *r.Size < 0 || !resourceHashPattern.MatchString(r.SHA256) {
return pkg, fmt.Errorf("无效的资源声明: %s", r.Target)
}
}
if len(manifest.Files) > 0 {
pkg.Warnings = append(pkg.Warnings, fmt.Sprintf("包内 %d 个 NPK/BK2 客户端资源不导入 PVF,请由登录器安装、关闭或恢复", len(manifest.Files)))
}
var total int
for _, op := range manifest.PVF {
if !components[op.Component] || ValidatePath(op.Path) != nil || op.Path != strings.ToLower(op.Path) || protectedPackagePath(op.Path) {
return pkg, fmt.Errorf("无效的 PVF 路径或组件: %s", op.Path)
}
if op.Mode != "add" && op.Mode != "replace" && op.Mode != "merge" {
return pkg, fmt.Errorf("不支持的 115CN PVF 操作 %q: %s;patch 标签补丁需完整协议,当前不执行", op.Mode, op.Path)
}
content, err := readPackageFile(root, op.Source, maxPayloadSize)
if err != nil {
return pkg, err
}
total += len(content)
if total > maxPackageSize {
return pkg, fmt.Errorf("MOD PVF 内容大小超出限制")
}
if !utf8.Valid(content) || strings.HasPrefix(string(content), "\ufeff") {
return pkg, fmt.Errorf("PVF 内容必须使用无 BOM 的 UTF-8: %s", op.Source)
}
e := Entry{Component: op.Component, Path: op.Path, Operation: ReplaceFile, Encoding: Text, DataType: op.Type, Data: content}
if e.DataType == 0 {
e.DataType = 1
if ext := path.Ext(op.Path); ext == ".str" || ext == ".nut" {
e.DataType = 3
}
}
if e.DataType != 1 && e.DataType != 3 {
return pkg, fmt.Errorf("不支持的 PVF 类型: %s", op.Path)
}
switch op.Mode {
case "add":
e.Operation = AddFile
case "merge":
switch path.Ext(op.Path) {
case ".lst":
e.Operation = MergeList
if e.DataType != 1 {
return pkg, fmt.Errorf("lst 合并类型必须为 1: %s", op.Path)
}
case ".str":
e.Operation = MergeStrings
if e.DataType != 3 {
return pkg, fmt.Errorf("str 合并类型必须为 3: %s", op.Path)
}
default:
return pkg, fmt.Errorf("merge 只支持 lst/str: %s", op.Path)
}
e.Pairs, err = ParseMergeText(string(content), e.Operation)
if err != nil {
return pkg, fmt.Errorf("%s: %w", op.Source, err)
}
e.Data = nil
}
checked := e
if checked.Operation == AddFile {
checked.Operation = ReplaceFile
}
if err := validateTextEntries([]Entry{checked}, CN115Format); err != nil {
return pkg, err
}
pkg.Entries = append(pkg.Entries, e)
}
if len(manifest.PVF)+len(manifest.Files) == 0 {
return pkg, fmt.Errorf("MOD 没有 PVF 操作或资源")
}
return pkg, nil
}
func validResourceTarget(target string) bool {
if ValidatePath(target) != nil || protectedPackagePath(target) {
return false
}
p := strings.ToLower(target)
if strings.HasPrefix(p, "imagepacks2/") || strings.HasPrefix(p, "soundpacks/") {
return strings.Count(p, "/") == 1 && path.Ext(p) == ".npk"
}
return (strings.HasPrefix(p, "video/character/") || strings.HasPrefix(p, "stream/video/")) && path.Ext(p) == ".bk2"
}
func validateComponents(components []Component) error {
if len(components) == 0 {
return fmt.Errorf("MOD 必须声明组件")
}
byID := map[string]Component{}
for _, c := range components {
if ValidateID(c.ID) != nil || c.Title == "" {
return fmt.Errorf("无效的组件 ID 或标题: %s", c.ID)
}
if _, exists := byID[c.ID]; exists {
return fmt.Errorf("重复的组件 ID: %s", c.ID)
}
byID[c.ID] = c
}
state := map[string]int{}
var visit func(string) error
visit = func(id string) error {
if state[id] == 1 {
return fmt.Errorf("组件存在循环依赖: %s", id)
}
if state[id] == 2 {
return nil
}
state[id] = 1
for _, dep := range byID[id].Requires {
if _, exists := byID[dep]; !exists {
return fmt.Errorf("组件 %s 依赖不存在的组件 %s", id, dep)
}
if err := visit(dep); err != nil {
return err
}
}
state[id] = 2
return nil
}
for id := range byID {
if err := visit(id); err != nil {
return err
}
}
return nil
}
// SelectComponents requires an explicit, valid selection. nil uses defaults;
// an empty non-nil list means no components, not "select everything".
func SelectComponents(pkg Package, ids []string) (Package, []string, error) {
if err := validateComponents(pkg.Components); err != nil {
return Package{}, nil, err
}
known := map[string]Component{}
for _, c := range pkg.Components {
known[c.ID] = c
}
if ids == nil {
ids = []string{}
for _, c := range pkg.Components {
if c.DefaultSelected {
ids = append(ids, c.ID)
}
}
}
selected := map[string]bool{}
groups := map[string]string{}
for _, id := range ids {
c, exists := known[id]
if !exists || selected[id] {
return Package{}, nil, fmt.Errorf("未知或重复的组件: %s", id)
}
selected[id] = true
if c.ExclusiveGroup != "" {
if previous := groups[c.ExclusiveGroup]; previous != "" {
return Package{}, nil, fmt.Errorf("互斥组件不能同时选择: %s / %s", previous, id)
}
groups[c.ExclusiveGroup] = id
}
}
for _, id := range ids {
for _, dep := range known[id].Requires {
if !selected[dep] {
return Package{}, nil, fmt.Errorf("组件 %s 需要同时选择 %s", id, dep)
}
}
}
result := pkg
result.Entries = nil
seen := map[string]bool{}
for _, e := range pkg.Entries {
if !selected[e.Component] {
continue
}
if seen[e.Path] {
return Package{}, nil, fmt.Errorf("所选组件存在重复 PVF 目标: %s", e.Path)
}
seen[e.Path] = true
result.Entries = append(result.Entries, e)
}
for p := range seen {
for parent := path.Dir(p); parent != "."; parent = path.Dir(parent) {
if seen[parent] {
return Package{}, nil, fmt.Errorf("PVF 文件与目录路径冲突: %s", p)
}
}
}
return result, append([]string{}, ids...), nil
}
func (CN115Writer) Validate(pkg Package) error {
if err := ValidateID(pkg.Metadata.ID); err != nil {
return err
}
plain := pkg
plain.Entries = append([]Entry(nil), pkg.Entries...)
for i := range plain.Entries {
e := &plain.Entries[i]
if e.Path != strings.ToLower(e.Path) || protectedPackagePath(e.Path) {
return fmt.Errorf("115CN PVF 路径必须为小写且不能是敏感路径: %s", e.Path)
}
if e.Operation == AddFile {
e.Operation = ReplaceFile
}
}
return validateTextPackage(plain, CN115Format)
}
func (CN115Writer) Files(pkg Package) []OutputFile {
files := []OutputFile{{Path: "mod.json"}}
for _, e := range pkg.Entries {
files = append(files, OutputFile{Path: "pvf/" + e.Path, EntryPath: e.Path})
}
return files
}
func cn115Output(pkg Package) (cn115Manifest, map[string][]byte) {
version := pkg.Metadata.Version
if version == "" {
version = "1.0.0"
}
m := cn115Manifest{Schema: 1, Client: "115CN", Kind: "resource", ID: pkg.Metadata.ID, Name: pkg.Metadata.Name, Version: version,
Components: []Component{{ID: "base", Title: "基础内容", DefaultSelected: true}}, Files: []cn115Resource{}, PVF: []cn115Operation{}}
payloads := map[string][]byte{}
for _, e := range pkg.Entries {
mode := "replace"
data := e.Data
dataType := e.DataType
switch e.Operation {
case AddFile:
mode = "add"
case MergeList, MergeStrings:
mode = "merge"
dataType = 1
if e.Operation == MergeStrings {
dataType = 3
}
var text strings.Builder
for _, pair := range e.Pairs {
if e.Operation == MergeList {
fmt.Fprintf(&text, "%s\t`%s`\n", pair.Key, pair.Value)
} else {
fmt.Fprintf(&text, "%s>%s\n", pair.Key, pair.Value)
}
}
data = []byte(text.String())
}
source := "pvf/" + e.Path
m.PVF = append(m.PVF, cn115Operation{Component: "base", Source: source, Path: e.Path, Mode: mode, Type: dataType})
payloads[source] = data
}
return m, payloads
}
func (w CN115Writer) Write(root string, pkg Package) error {
if err := w.Validate(pkg); err != nil {
return err
}
m, payloads := cn115Output(pkg)
manifest, err := json.MarshalIndent(m, "", " ")
if err != nil {
return err
}
payloads["mod.json"] = append(manifest, '\n')
for _, file := range w.Files(pkg) {
dest := filepath.Join(root, filepath.FromSlash(file.Path))
if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil {
return err
}
if err := os.WriteFile(dest, payloads[file.Path], 0644); err != nil {
return err
}
}
return nil
}
func (w CN115Writer) WriteArchive(output io.Writer, pkg Package) error {
if err := w.Validate(pkg); err != nil {
return err
}
m, payloads := cn115Output(pkg)
manifest, err := json.MarshalIndent(m, "", " ")
if err != nil {
return err
}
payloads["mod.json"] = append(manifest, '\n')
z := zip.NewWriter(output)
for _, file := range w.Files(pkg) {
f, err := z.Create(file.Path)
if err != nil {
z.Close()
return err
}
if _, err = f.Write(payloads[file.Path]); err != nil {
z.Close()
return err
}
}
return z.Close()
}
+357
View File
@@ -0,0 +1,357 @@
package mod
import (
"archive/zip"
"bytes"
"encoding/json"
"io/fs"
"os"
"path/filepath"
"strings"
"testing"
"testing/fstest"
"golang.org/x/text/encoding/simplifiedchinese"
)
func cn115Fixture(t *testing.T) (cn115Manifest, fstest.MapFS) {
t.Helper()
m := cn115Manifest{
Schema: 1, Client: "115CN", Kind: "resource", ID: "unique-test-mod", Name: "测试 MOD", Version: "1.0.0",
Components: []Component{
{ID: "base", Title: "基础", DefaultSelected: true},
{ID: "optional", Title: "可选", Requires: []string{"base"}},
},
PVF: []cn115Operation{
{Component: "base", Source: "payload/item.stk", Path: "stackable/demo.stk", Mode: "replace", Type: 1},
{Component: "base", Source: "payload/list.lst", Path: "list/stackable.lst", Mode: "merge", Type: 1},
{Component: "optional", Source: "payload/text.str", Path: "string/demo.str", Mode: "merge", Type: 3},
{Component: "optional", Source: "payload/new.stk", Path: "stackable/new.stk", Mode: "add"},
},
}
files := fstest.MapFS{
"payload/item.stk": {Data: []byte("[price]\n200")},
"payload/new.stk": {Data: []byte("[price]\n300")},
"payload/list.lst": {Data: []byte("2\t`stackable/new.stk`\r\n")},
"payload/text.str": {Data: []byte("name> 新名称 > \r\nempty>\r\n")},
}
setCN115Manifest(t, files, m)
return m, files
}
func setCN115Manifest(t *testing.T, files fstest.MapFS, m cn115Manifest) {
t.Helper()
data, err := json.Marshal(m)
if err != nil {
t.Fatal(err)
}
files["mod.json"] = &fstest.MapFile{Data: data}
}
func TestCN115ReadAndSelection(t *testing.T) {
_, files := cn115Fixture(t)
for _, prefix := range []string{"", "中文包/"} {
t.Run(prefix, func(t *testing.T) {
root := fstest.MapFS{}
for p, f := range files {
root[prefix+p] = f
}
pkg, err := (CN115Reader{}).Read(root)
if err != nil {
t.Fatal(err)
}
defaults, ids, err := SelectComponents(pkg, nil)
if err != nil || len(ids) != 1 || ids[0] != "base" || len(defaults.Entries) != 2 {
t.Fatalf("defaults: %+v %v %v", defaults, ids, err)
}
all, _, err := SelectComponents(pkg, []string{"base", "optional"})
if err != nil || len(all.Entries) != 4 || all.Entries[3].Operation != AddFile || all.Entries[2].Pairs[0].Value != " 新名称 > " {
t.Fatalf("all: %+v %v", all, err)
}
empty, _, err := SelectComponents(pkg, []string{})
if err != nil || len(empty.Entries) != 0 {
t.Fatal("empty selection became defaults", err)
}
for _, ids := range [][]string{{"optional"}, {"unknown"}, {"base", "base"}} {
if _, _, err := SelectComponents(pkg, ids); err == nil {
t.Fatalf("accepted invalid selection %v", ids)
}
}
})
}
}
func TestCN115RejectMalformedPackages(t *testing.T) {
for _, kind := range []string{"schema", "client", "kind", "id", "component", "cycle", "missing-dependency", "target", "uppercase", "source", "sensitive", "missing-source", "patch", "type", "merge-type", "merge-extension", "duplicate-key", "encoding", "parent-symlink", "file-symlink", "nested-root"} {
t.Run(kind, func(t *testing.T) {
m, files := cn115Fixture(t)
switch kind {
case "schema":
m.Schema = 2
case "client":
m.Client = "110US"
case "kind":
m.Kind = "script"
case "id":
m.ID = "Not-Unique"
case "component":
m.PVF[0].Component = "unknown"
case "cycle":
m.Components[0].Requires = []string{"optional"}
case "missing-dependency":
m.Components[0].Requires = []string{"unknown"}
case "target":
m.PVF[0].Path = "../escape.stk"
case "uppercase":
m.PVF[0].Path = "Stackable/demo.stk"
case "source":
m.PVF[0].Source = "../escape"
case "sensitive":
m.PVF[0].Source = ".env"
case "missing-source":
delete(files, "payload/item.stk")
case "patch":
m.PVF[0].Mode = "patch"
case "type":
m.PVF[0].Type = 9
case "merge-type":
m.PVF[1].Type = 3
case "merge-extension":
m.PVF[1].Path = "etc/merge.etc"
case "duplicate-key":
files["payload/list.lst"].Data = []byte("2 `a`\n2 `b`")
case "encoding":
files["payload/item.stk"].Data = []byte{0xff}
case "parent-symlink":
files["payload"] = &fstest.MapFile{Mode: fs.ModeSymlink}
case "file-symlink":
files["payload/item.stk"].Mode = fs.ModeSymlink
case "nested-root":
nested := fstest.MapFS{}
setCN115Manifest(t, files, m)
for p, f := range files {
nested["one/two/"+p] = f
}
files = nested
}
if kind != "nested-root" {
setCN115Manifest(t, files, m)
}
if _, err := (CN115Reader{}).Read(files); err == nil {
t.Fatal("accepted malformed package")
}
})
}
}
func TestCN115SelectionConflicts(t *testing.T) {
m, files := cn115Fixture(t)
m.Components[0].ExclusiveGroup = "variant"
m.Components[1].ExclusiveGroup = "variant"
setCN115Manifest(t, files, m)
pkg, err := (CN115Reader{}).Read(files)
if err != nil {
t.Fatal(err)
}
if _, _, err := SelectComponents(pkg, []string{"base", "optional"}); err == nil {
t.Fatal("exclusive group ignored")
}
pkg.Components[0].ExclusiveGroup = ""
pkg.Components[1].ExclusiveGroup = ""
pkg.Entries[3].Path = pkg.Entries[0].Path
if _, _, err := SelectComponents(pkg, []string{"base", "optional"}); err == nil {
t.Fatal("duplicate target ignored")
}
if _, _, err := SelectComponents(pkg, []string{"base"}); err != nil {
t.Fatal("unselected alternative blocked selection", err)
}
pkg.Entries[3].Path = "stackable/demo.stk/child"
if _, _, err := SelectComponents(pkg, []string{"base", "optional"}); err == nil {
t.Fatal("file/directory conflict ignored")
}
}
func TestCN115WriteRoundTrip(t *testing.T) {
pkg := samplePackage()
pkg.Metadata.ID = "my-unique-mod"
pkg.Metadata.Version = "2.0.0"
writer := CN115Writer{}
if err := writer.Validate(pkg); err != nil {
t.Fatal(err)
}
dir := t.TempDir()
if err := writer.Write(dir, pkg); err != nil {
t.Fatal(err)
}
var data bytes.Buffer
if err := writer.WriteArchive(&data, pkg); err != nil {
t.Fatal(err)
}
z, err := zip.NewReader(bytes.NewReader(data.Bytes()), int64(data.Len()))
if err != nil {
t.Fatal(err)
}
for _, root := range []fs.FS{os.DirFS(dir), z} {
got, err := (CN115Reader{}).Read(root)
if err != nil || got.Metadata != pkg.Metadata || len(got.Entries) != len(pkg.Entries) {
t.Fatalf("roundtrip: %+v %v", got, err)
}
for i, entry := range got.Entries {
if entry.Path != pkg.Entries[i].Path || entry.Operation != pkg.Entries[i].Operation {
t.Fatalf("entry changed: %+v", entry)
}
}
}
pkg.Metadata.ID = ""
if err := writer.Validate(pkg); err == nil {
t.Fatal("missing unique ID accepted")
}
}
func TestCN115ZIPSource(t *testing.T) {
_, files := cn115Fixture(t)
prefix, err := simplifiedchinese.GB18030.NewEncoder().String("中文包")
if err != nil {
t.Fatal(err)
}
for _, kind := range []string{"root", "wrapper", "gbk", "traversal", "case-collision", "symlink", "file-directory"} {
t.Run(kind, func(t *testing.T) {
var buffer bytes.Buffer
z := zip.NewWriter(&buffer)
for p, f := range files {
name := p
switch kind {
case "wrapper":
name = "中文包/" + p
case "gbk":
name = prefix + "/" + p
}
header := &zip.FileHeader{Name: name, NonUTF8: kind == "gbk"}
w, err := z.CreateHeader(header)
if err != nil {
t.Fatal(err)
}
if _, err := w.Write(f.Data); err != nil {
t.Fatal(err)
}
}
switch kind {
case "traversal":
_, err = z.Create("../escape")
case "case-collision":
_, err = z.Create("MOD.JSON")
case "symlink":
header := &zip.FileHeader{Name: "link"}
header.SetMode(fs.ModeSymlink | 0777)
_, err = z.CreateHeader(header)
case "file-directory":
_, err = z.Create("payload")
}
if err != nil {
t.Fatal(err)
}
if err := z.Close(); err != nil {
t.Fatal(err)
}
filename := filepath.Join(t.TempDir(), "package.dnfmod")
if err := os.WriteFile(filename, buffer.Bytes(), 0644); err != nil {
t.Fatal(err)
}
root, closer, err := OpenSource(filename, true)
valid := kind == "root" || kind == "wrapper" || kind == "gbk"
if valid {
if err != nil {
t.Fatal(err)
}
defer closer.Close()
if _, err := (CN115Reader{}).Read(root); err != nil {
t.Fatal(err)
}
} else if err == nil {
closer.Close()
t.Fatal("unsafe ZIP accepted")
}
if _, closer, err := OpenSource(filename, false); err == nil {
closer.Close()
t.Fatal("ZIP accepted by directory-only format")
}
jar := filename + ".jar"
if err := os.WriteFile(jar, buffer.Bytes(), 0644); err != nil {
t.Fatal(err)
}
if _, closer, err := OpenSource(jar, true); err == nil {
closer.Close()
t.Fatal("JAR accepted as a MOD ZIP")
}
})
}
}
func TestCN115ZIPSizeLimit(t *testing.T) {
var buffer bytes.Buffer
z := zip.NewWriter(&buffer)
if _, err := z.CreateRaw(&zip.FileHeader{Name: "huge.stk", UncompressedSize64: maxPayloadSize + 1}); err != nil {
t.Fatal(err)
}
if err := z.Close(); err != nil {
t.Fatal(err)
}
filename := filepath.Join(t.TempDir(), "oversize.zip")
if err := os.WriteFile(filename, buffer.Bytes(), 0644); err != nil {
t.Fatal(err)
}
if _, closer, err := OpenSource(filename, true); err == nil {
closer.Close()
t.Fatal("ZIP declared expansion limit ignored")
}
}
func TestCN115ResourceWarningsAndLimits(t *testing.T) {
m, files := cn115Fixture(t)
size := int64(20)
m.Files = []cn115Resource{{Component: "base", Source: "payload/resource.npk", Target: "ImagePacks2/unique.npk", Size: &size, SHA256: strings.Repeat("a", 64)}}
m.Requires = []string{"other-mod"}
setCN115Manifest(t, files, m)
pkg, err := (CN115Reader{}).Read(files)
if err != nil || len(pkg.Warnings) != 2 {
t.Fatal("missing resource/dependency warnings", pkg.Warnings, err)
}
for _, target := range []string{"../a.npk", "bin/file.dll", "SoundPacks/sub/file.npk", "Stream/Video/movie.exe"} {
m.Files[0].Target = target
setCN115Manifest(t, files, m)
if _, err := (CN115Reader{}).Read(files); err == nil {
t.Fatalf("accepted resource target %s", target)
}
}
if _, err := readPackageFile(files, "payload/item.stk", 2); err == nil {
t.Fatal("payload limit ignored")
}
}
func TestCN115RealMOD(t *testing.T) {
filename := os.Getenv("PVF_MOD_TESTFILE")
if filename == "" {
t.Skip("PVF_MOD_TESTFILE required")
}
root, closer, err := OpenSource(filename, true)
if err != nil {
t.Fatal(err)
}
defer closer.Close()
pkg, err := (CN115Reader{}).Read(root)
if err != nil {
t.Fatal(err)
}
pkg, ids, err := SelectComponents(pkg, nil)
if err != nil {
t.Fatal(err)
}
counts := map[Operation]int{}
for _, entry := range pkg.Entries {
counts[entry.Operation]++
}
t.Logf("MOD: %s %s; selected=%v entries=%d operations=%v", pkg.Metadata.Name, pkg.Metadata.Version, ids, len(pkg.Entries), counts)
if len(pkg.Entries) == 0 {
t.Fatal("no PVF operations")
}
}
+147
View File
@@ -0,0 +1,147 @@
package mod
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"unicode/utf8"
)
type Extend110Writer struct{}
func (Extend110Writer) Files(pkg Package) []OutputFile {
files := []OutputFile{{Path: "pack.json"}}
for _, e := range pkg.Entries {
dir := "pvf"
if e.Operation != ReplaceFile {
dir = "merge"
}
files = append(files, OutputFile{Path: dir + "/" + e.Path, EntryPath: e.Path})
}
return files
}
func (Extend110Writer) Validate(pkg Package) error {
return validateTextPackage(pkg, "110USextend")
}
func validateTextPackage(pkg Package, format string) error {
if err := ValidateName(pkg.Metadata.Name); err != nil {
return err
}
if err := ValidateVersion(pkg.Metadata.Version); err != nil {
return err
}
return validateTextEntries(pkg.Entries, format)
}
func validateTextEntries(entries []Entry, format string) error {
if len(entries) == 0 {
return fmt.Errorf("没有可应用的 mod 内容")
}
seen := make(map[string]bool)
for _, e := range entries {
if err := ValidatePath(e.Path); err != nil {
return err
}
key := strings.ToLower(e.Path)
if seen[key] {
return fmt.Errorf("mod 路径冲突: %s", e.Path)
}
seen[key] = true
switch e.Operation {
case ReplaceFile:
if e.Encoding != Text || (e.DataType != 1 && e.DataType != 3) || !utf8.Valid(e.Data) || strings.HasPrefix(string(e.Data), "\ufeff") {
return fmt.Errorf("%s 不支持文件的内容类型或编码: %s", format, e.Path)
}
case MergeList, MergeStrings:
want := ".lst"
if e.Operation == MergeStrings {
want = ".str"
}
if !strings.EqualFold(filepath.Ext(e.Path), want) || len(e.Pairs) == 0 {
return fmt.Errorf("无效的合并文件: %s", e.Path)
}
keys := make(map[string]bool)
for _, pair := range e.Pairs {
if pair.Key == "" || strings.ContainsAny(pair.Key+pair.Value, "\r\n\x00") || !utf8.ValidString(pair.Key+pair.Value) || keys[pair.Key] {
return fmt.Errorf("无效或重复的合并条目: %s / %s", e.Path, pair.Key)
}
keys[pair.Key] = true
if e.Operation == MergeList {
n, err := strconv.ParseUint(pair.Key, 10, 32)
if err != nil || strconv.FormatUint(n, 10) != pair.Key || strings.Contains(pair.Value, "`") {
return fmt.Errorf("%s 列表编号或路径无法表达: %s / %s", format, e.Path, pair.Key)
}
if err := ValidatePath(pair.Value); err != nil {
return err
}
} else if strings.Contains(pair.Key, ">") || strings.HasPrefix(pair.Key, "//") || strings.HasPrefix(pair.Key, "\ufeff") {
return fmt.Errorf("%s 文字表键无法表达: %s / %s", format, e.Path, pair.Key)
}
}
default:
return fmt.Errorf("%s 不支持操作 %s: %s", format, e.Operation, e.Path)
}
}
// Detect file/directory collisions too, including across pvf and merge.
for key := range seen {
for p := key; strings.Contains(p, "/"); {
p = p[:strings.LastIndex(p, "/")]
if seen[p] {
return fmt.Errorf("mod 文件与目录路径冲突: %s", key)
}
}
}
return nil
}
func (w Extend110Writer) Write(root string, pkg Package) error {
if err := w.Validate(pkg); err != nil {
return err
}
types := make(map[string]int32)
for _, e := range pkg.Entries {
dir, data := "pvf", e.Data
if e.Operation != ReplaceFile {
dir = "merge"
var text strings.Builder
for _, pair := range e.Pairs {
if e.Operation == MergeList {
fmt.Fprintf(&text, "%s\t`%s`\n", pair.Key, pair.Value)
} else {
fmt.Fprintf(&text, "%s>%s\n", pair.Key, pair.Value)
}
}
data = []byte(text.String())
} else {
types[e.Path] = e.DataType
}
dest := filepath.Join(root, dir, filepath.FromSlash(e.Path))
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return err
}
if err := os.WriteFile(dest, data, 0o644); err != nil {
return err
}
}
version := pkg.Metadata.Version
if version == "" {
version = "1.0"
}
manifest := struct {
Name string `json:"name"`
Version string `json:"version"`
Priority int `json:"priority"`
Enabled bool `json:"enabled"`
Types map[string]int32 `json:"types"`
}{pkg.Metadata.Name, version, 100, true, types}
data, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return err
}
return os.WriteFile(filepath.Join(root, "pack.json"), append(data, '\n'), 0o644)
}
+191
View File
@@ -0,0 +1,191 @@
package mod
import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"path"
"strconv"
"strings"
"unicode/utf8"
)
// Extend110Reader reads only the package manifest and its PVF payloads.
// Resource directories are deliberately outside the archive import contract.
type Extend110Reader struct{}
func (Extend110Reader) Detect(root fs.FS) (bool, error) {
entries, err := fs.ReadDir(root, ".")
if err != nil {
return false, err
}
for _, entry := range entries {
if entry.Name() == "pack.json" {
if !entry.Type().IsRegular() {
return false, fmt.Errorf("pack.json 必须是普通文件")
}
return true, nil
}
}
return false, nil
}
func (Extend110Reader) Read(root fs.FS) (Package, error) {
var pkg Package
if ok, err := (Extend110Reader{}).Detect(root); err != nil {
return pkg, err
} else if !ok {
return pkg, fmt.Errorf("缺少 pack.json")
}
data, err := fs.ReadFile(root, "pack.json")
if err != nil {
return pkg, err
}
var manifest struct {
Name string `json:"name"`
Version string `json:"version"`
Types map[string]int32 `json:"types"`
}
if !utf8.Valid(data) {
return pkg, fmt.Errorf("pack.json 不是有效的 UTF-8")
}
if err := json.Unmarshal(data, &manifest); err != nil {
return pkg, fmt.Errorf("解析 pack.json: %w", err)
}
pkg.Metadata = Metadata{Name: manifest.Name, Version: manifest.Version}
for p, t := range manifest.Types {
if err := ValidatePath(p); err != nil {
return pkg, err
}
if t != 1 && t != 3 {
return pkg, fmt.Errorf("不支持的 PVF 类型: %s", p)
}
}
usedTypes := map[string]bool{}
for _, dir := range []string{"pvf", "merge"} {
err := fs.WalkDir(root, dir, func(p string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
if p == dir && errors.Is(walkErr, fs.ErrNotExist) {
return nil
}
return walkErr
}
if entry.Type()&fs.ModeSymlink != 0 {
return fmt.Errorf("mod 不允许符号链接: %s", p)
}
if entry.IsDir() {
return nil
}
if !entry.Type().IsRegular() {
return fmt.Errorf("mod 内容不是普通文件: %s", p)
}
target := strings.TrimPrefix(p, dir+"/")
if target == p {
return fmt.Errorf("mod 内容目录无效: %s", p)
}
if err := ValidatePath(target); err != nil {
return err
}
// Never read local secrets accidentally placed in a package.
if protectedPackagePath(target) {
return fmt.Errorf("mod 包含敏感配置路径,拒绝读取")
}
content, err := fs.ReadFile(root, p)
if err != nil {
return err
}
if !utf8.Valid(content) || strings.HasPrefix(string(content), "\ufeff") {
return fmt.Errorf("mod 文件必须为无 BOM 的 UTF-8: %s", p)
}
e := Entry{Path: target, Operation: ReplaceFile, Encoding: Text, Data: content}
if dir == "pvf" {
t, ok := manifest.Types[target]
if !ok {
return fmt.Errorf("pack.json 缺少文件类型: %s", target)
}
e.DataType = t
usedTypes[target] = true
} else {
switch strings.ToLower(path.Ext(target)) {
case ".lst":
e.Operation = MergeList
case ".str":
e.Operation = MergeStrings
default:
return fmt.Errorf("不支持的合并文件: %s", target)
}
e.Data = nil
e.Pairs, err = ParseMergeText(string(content), e.Operation)
if err != nil {
return fmt.Errorf("%s: %w", p, err)
}
}
pkg.Entries = append(pkg.Entries, e)
return nil
})
if err != nil {
return pkg, err
}
}
for p := range manifest.Types {
if !usedTypes[p] {
return pkg, fmt.Errorf("pack.json 引用了不存在的文件: %s", p)
}
}
if err := (Extend110Writer{}).Validate(pkg); err != nil {
return pkg, err
}
return pkg, nil
}
func protectedPackagePath(p string) bool {
for _, part := range strings.Split(strings.ToLower(p), "/") {
if part == ".env" || strings.HasPrefix(part, ".env.") || part == "credentials" || part == "credentials.json" || part == ".ssh" || part == ".aws" || part == ".gnupg" || part == "id_rsa" || part == "id_ed25519" || strings.HasSuffix(part, ".key") || strings.HasSuffix(part, ".pem") {
return true
}
}
return false
}
// ParseMergeText keeps string values verbatim, including spaces and empty values.
// Malformed or duplicate incoming rows fail the whole import rather than losing data.
func ParseMergeText(text string, operation Operation) ([]Pair, error) {
if operation != MergeList && operation != MergeStrings {
return nil, fmt.Errorf("不支持的合并操作: %s", operation)
}
pairs := []Pair{}
seen := map[string]bool{}
for i, line := range strings.Split(text, "\n") {
line = strings.TrimSuffix(line, "\r")
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "//") || (operation == MergeList && trimmed == "#PVF_File") {
continue
}
var pair Pair
if operation == MergeStrings {
key, value, ok := strings.Cut(line, ">")
if !ok || key == "" {
return nil, fmt.Errorf("第 %d 行不是有效的文字表条目", i+1)
}
pair = Pair{key, value}
} else {
start := strings.IndexByte(trimmed, '`')
if start <= 0 || !strings.HasSuffix(trimmed, "`") {
return nil, fmt.Errorf("第 %d 行不是有效的列表条目", i+1)
}
id := strings.TrimSpace(trimmed[:start])
n, err := strconv.ParseUint(id, 10, 32)
if err != nil || strconv.FormatUint(n, 10) != id {
return nil, fmt.Errorf("第 %d 行列表编号无效", i+1)
}
pair = Pair{id, trimmed[start+1 : len(trimmed)-1]}
}
if seen[pair.Key] {
return nil, fmt.Errorf("第 %d 行合并键重复", i+1)
}
seen[pair.Key] = true
pairs = append(pairs, pair)
}
return pairs, nil
}
+91
View File
@@ -0,0 +1,91 @@
package mod
import (
"io/fs"
"os"
"path/filepath"
"reflect"
"testing"
"testing/fstest"
)
func TestExtend110RoundTrip(t *testing.T) {
pkg := samplePackage()
pkg.Metadata.Version = "2.3-beta"
root := t.TempDir()
if err := (Extend110Writer{}).Write(root, pkg); err != nil {
t.Fatal(err)
}
got, err := (Extend110Reader{}).Read(os.DirFS(root))
if err != nil {
t.Fatal(err)
}
if got.Metadata != pkg.Metadata {
t.Fatalf("metadata = %#v", got.Metadata)
}
byPath := map[string]Entry{}
for _, e := range got.Entries {
byPath[e.Path] = e
}
for _, want := range pkg.Entries {
actual := byPath[want.Path]
// Merges carry semantic pairs, not a payload encoding.
if want.Operation != ReplaceFile {
actual.Encoding = want.Encoding
}
if !reflect.DeepEqual(actual, want) {
t.Fatalf("entry %s = %#v; want %#v", want.Path, actual, want)
}
}
}
func TestExtend110ReaderRejectsInvalidPackage(t *testing.T) {
cases := map[string]fstest.MapFS{
"traversal": {"pack.json": {Data: []byte(`{"name":"test","types":{"../x":1}}`)}},
"missing type": {"pack.json": {Data: []byte(`{"name":"test"}`)}, "pvf/x.stk": {Data: []byte("[name]\n`x`")}},
"invalid type": {"pack.json": {Data: []byte(`{"name":"test","types":{"x.stk":9}}`)}, "pvf/x.stk": {Data: []byte("x")}},
"missing payload": {"pack.json": {Data: []byte(`{"name":"test","types":{"x.stk":1}}`)}},
"duplicate targets": {"pack.json": {Data: []byte(`{"name":"test","types":{"x.str":3}}`)}, "pvf/x.str": {Data: []byte("a>b")}, "merge/X.str": {Data: []byte("a>c")}},
"duplicate keys": {"pack.json": {Data: []byte(`{"name":"test"}`)}, "merge/x.str": {Data: []byte("a>b\na>c")}},
"invalid list": {"pack.json": {Data: []byte(`{"name":"test"}`)}, "merge/x.lst": {Data: []byte("1 `unterminated")}},
"invalid utf8": {"pack.json": {Data: []byte(`{"name":"test"}`)}, "merge/x.str": {Data: []byte{0xff}}},
"bom": {"pack.json": {Data: []byte(`{"name":"test"}`)}, "merge/x.str": {Data: []byte("\ufeffa>b")}},
"sensitive": {"pack.json": {Data: []byte(`{"name":"test","types":{".env":3}}`)}, "pvf/.env": {Data: []byte("synthetic fixture")}},
"empty": {"pack.json": {Data: []byte(`{"name":"test"}`)}},
"symlink manifest": {"pack.json": {Mode: fs.ModeSymlink, Data: []byte("manifest")}},
}
for name, files := range cases {
t.Run(name, func(t *testing.T) {
if _, err := (Extend110Reader{}).Read(files); err == nil {
t.Fatal("invalid package accepted")
}
})
}
}
func TestExtend110RejectsSymlinks(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "pack.json"), []byte(`{"name":"test","types":{"x.str":3}}`), 0600); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(root, "pvf"), 0700); err != nil {
t.Fatal(err)
}
outside := filepath.Join(t.TempDir(), "fixture.str")
if err := os.WriteFile(outside, []byte("a>b"), 0600); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, filepath.Join(root, "pvf", "x.str")); err != nil {
t.Skip(err)
}
if _, err := (Extend110Reader{}).Read(os.DirFS(root)); err == nil {
t.Fatal("symlink accepted")
}
}
func TestParseMergeStringsPreservesValues(t *testing.T) {
pairs, err := ParseMergeText(" // comment\r\na> text > tail \r\nb>\n", MergeStrings)
if err != nil || !reflect.DeepEqual(pairs, []Pair{{"a", " text > tail "}, {"b", ""}}) {
t.Fatalf("pairs = %#v, %v", pairs, err)
}
}
+175
View File
@@ -0,0 +1,175 @@
package mod
import (
"encoding/json"
"io/fs"
"os"
"path/filepath"
"strings"
"testing"
"testing/fstest"
)
func samplePackage() Package {
return Package{Metadata: Metadata{Name: "中文测试"}, Entries: []Entry{
{Path: "stackable/demo.stk", Operation: ReplaceFile, Encoding: Text, DataType: 1, Data: []byte("[name]\n`中文`")},
{Path: "list/stackable.lst", Operation: MergeList, Pairs: []Pair{{"123", "stackable/demo.stk"}}},
{Path: "string/demo.str", Operation: MergeStrings, Pairs: []Pair{{"name", "中文>说明"}, {"empty", ""}}},
}}
}
func TestExtend110Write(t *testing.T) {
root := t.TempDir()
if err := (Extend110Writer{}).Write(root, samplePackage()); err != nil {
t.Fatal(err)
}
for p, want := range map[string]string{
"pvf/stackable/demo.stk": "[name]\n`中文`",
"merge/list/stackable.lst": "123\t`stackable/demo.stk`\n",
"merge/string/demo.str": "name>中文>说明\nempty>\n",
} {
data, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(p)))
if err != nil || string(data) != want || strings.HasPrefix(string(data), "\ufeff") {
t.Fatalf("%s = %q, error %v", p, data, err)
}
}
var manifest struct {
Name, Version string
Priority int
Enabled bool
Types map[string]int32
}
data, err := os.ReadFile(filepath.Join(root, "pack.json"))
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(data, &manifest); err != nil {
t.Fatal(err)
}
if manifest.Name != "中文测试" || manifest.Version != "1.0" || manifest.Priority != 100 || !manifest.Enabled || manifest.Types["stackable/demo.stk"] != 1 {
t.Fatalf("manifest = %#v", manifest)
}
if _, err := os.Stat(filepath.Join(root, "sprite")); !os.IsNotExist(err) {
t.Fatal("unused resource directory created")
}
}
func TestExtend110CustomVersion(t *testing.T) {
pkg := samplePackage()
pkg.Metadata.Version = "2.1-beta"
root := t.TempDir()
if err := (Extend110Writer{}).Write(root, pkg); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(root, "pack.json"))
if err != nil || !strings.Contains(string(data), `"version": "2.1-beta"`) {
t.Fatalf("custom manifest = %s, %v", data, err)
}
for _, invalid := range []string{" 1.0", "1.0 ", "1\n2", strings.Repeat("x", 129)} {
pkg.Metadata.Version = invalid
if err := (Extend110Writer{}).Validate(pkg); err == nil {
t.Fatalf("invalid version accepted: %q", invalid)
}
}
}
func TestExtend110RejectsUnsupportedContentAndCollisions(t *testing.T) {
cases := []Entry{
{Path: "../outside", Operation: ReplaceFile, Encoding: Text, DataType: 1},
{Path: "file.equ", Operation: DeleteFile},
{Path: "data.bin", Operation: ReplaceFile, Encoding: Bytes, DataType: 9},
{Path: "list/test.lst", Operation: MergeList, Pairs: []Pair{{"text-id", "item/a"}}},
{Path: "list/test.lst", Operation: MergeList, Pairs: []Pair{{"1", "../outside"}}},
{Path: "string/test.str", Operation: MergeStrings, Pairs: []Pair{{"bad>key", "value"}}},
{Path: "string/test.str", Operation: MergeStrings, Pairs: []Pair{{"key", "bad\nline"}}},
}
for _, entry := range cases {
pkg := Package{Metadata: Metadata{Name: "test"}, Entries: []Entry{entry}}
if err := (Extend110Writer{}).Validate(pkg); err == nil {
t.Fatalf("accepted %#v", entry)
}
}
pkg := samplePackage()
pkg.Entries = append(pkg.Entries, Entry{Path: "STACKABLE/DEMO.STK", Operation: ReplaceFile, Encoding: Text, DataType: 1})
if err := (Extend110Writer{}).Validate(pkg); err == nil {
t.Fatal("accepted case-insensitive duplicate")
}
pkg = samplePackage()
pkg.Entries = append(pkg.Entries, Entry{Path: "stackable", Operation: ReplaceFile, Encoding: Text, DataType: 1})
if err := (Extend110Writer{}).Validate(pkg); err == nil {
t.Fatal("accepted file/directory collision")
}
}
func TestNameAndPathValidation(t *testing.T) {
for _, name := range []string{"", ".hidden", "_hidden", "a/b", "a\\b", "CON", "LPT1.txt", "COM¹", strings.Repeat("a", 256), "name.", "name ", " name", "a:b", "a\x00b"} {
if err := ValidateName(name); err == nil {
t.Errorf("accepted name %q", name)
}
}
for _, p := range []string{"/root", "dir//file", "../file", "dir/../file", "dir/NUL.txt", "dir/file.", "C:/file", "dir\\file"} {
if err := ValidatePath(p); err == nil {
t.Errorf("accepted path %q", p)
}
}
if err := ValidateName("合法名称 MOD"); err != nil {
t.Fatal(err)
}
}
type testReader struct{}
func (testReader) Detect(source fs.FS) (bool, error) {
_, err := fs.Stat(source, "test.marker")
return err == nil, nil
}
func (testReader) Read(fs.FS) (Package, error) { return samplePackage(), nil }
func TestRegistrySeparatesReadWriteCapabilities(t *testing.T) {
registry := DefaultRegistry()
if err := registry.Register(Format{Descriptor: Descriptor{ID: "read-only"}, Reader: testReader{}}); err != nil {
t.Fatal(err)
}
reader, err := registry.Get("read-only")
if err != nil || !reader.Descriptor.CanRead || reader.Descriptor.CanWrite || reader.Writer != nil {
t.Fatalf("reader = %#v, error %v", reader, err)
}
detected, err := reader.Reader.Detect(fstest.MapFS{"test.marker": &fstest.MapFile{}})
if err != nil || !detected {
t.Fatal("reader detection failed")
}
pkg, err := reader.Reader.Read(fstest.MapFS{})
if err != nil || len(pkg.Entries) != 3 {
t.Fatal("reader did not produce neutral package")
}
writer, _ := registry.Get("110USextend")
if !writer.Descriptor.CanRead || !writer.Descriptor.CanWrite || writer.Reader == nil {
t.Fatal("writer capabilities incorrect")
}
writer.Descriptor.Operations[0] = DeleteFile
again, _ := registry.Get("110USextend")
if again.Descriptor.Operations[0] == DeleteFile {
t.Fatal("descriptor leaked mutable registry state")
}
if err := registry.Register(writer); err == nil {
t.Fatal("duplicate registry entry accepted")
}
}
func TestExtend110OutputLayout(t *testing.T) {
pkg := samplePackage()
root := t.TempDir()
writer := Extend110Writer{}
if err := writer.Write(root, pkg); err != nil {
t.Fatal(err)
}
files := writer.Files(pkg)
if len(files) != len(pkg.Entries)+1 || files[0].Path != "pack.json" || files[0].EntryPath != "" {
t.Fatalf("layout = %#v", files)
}
for _, file := range files {
if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(file.Path))); err != nil {
t.Fatalf("declared output does not exist: %s: %v", file.Path, err)
}
}
}
+122
View File
@@ -0,0 +1,122 @@
// Package mod defines format-independent packages and separately registered
// readers and writers. It does not know about the editor or version repository.
package mod
import (
"fmt"
"strings"
"unicode/utf16"
"unicode/utf8"
)
type Operation string
type Encoding string
const (
AddFile Operation = "add"
ReplaceFile Operation = "replace"
MergeList Operation = "merge-list"
MergeStrings Operation = "merge-strings"
DeleteFile Operation = "delete"
Text Encoding = "text"
Bytes Encoding = "bytes"
)
type Metadata struct {
ID string
Name string
Version string
}
func ValidateVersion(version string) error {
if version == "" {
return nil // Older callers retain the format's default version.
}
if !utf8.ValidString(version) || version != strings.TrimSpace(version) || len(version) > 128 {
return fmt.Errorf("mod 版本号不能包含首尾空格或超过 128 字节")
}
for _, r := range version {
if r < 32 || r == 127 {
return fmt.Errorf("mod 版本号不能包含控制字符")
}
}
return nil
}
type Pair struct {
Key string
Value string
}
// Entry keeps payload representation explicit. Merge entries contain semantic
// pairs rather than format-specific source lines.
type Entry struct {
Component string
Path string
Operation Operation
Encoding Encoding
DataType int32
Data []byte
Pairs []Pair
}
type Package struct {
Metadata Metadata
Entries []Entry
Components []Component
Warnings []string
}
type Component struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
DefaultSelected bool `json:"defaultSelected"`
Requires []string `json:"requires"`
ExclusiveGroup string `json:"exclusiveGroup"`
}
func ValidateName(name string) error {
if name == "" || name != strings.TrimSpace(name) || strings.HasPrefix(name, ".") || strings.HasPrefix(name, "_") {
return fmt.Errorf("mod 名称不能为空、包含首尾空格或以 . / _ 开头")
}
return validateComponent(name)
}
// ValidatePath rejects names instead of sanitizing them: renaming package
// files would invalidate references inside scripts and lists.
func ValidatePath(p string) error {
if p == "" || strings.Contains(p, "\\") {
return fmt.Errorf("无效的 mod 路径: %q", p)
}
for _, part := range strings.Split(p, "/") {
if err := validateComponent(part); err != nil {
return fmt.Errorf("无效的 mod 路径 %q: %w", p, err)
}
}
return nil
}
func validateComponent(name string) error {
if name == "" || name == "." || name == ".." || strings.HasSuffix(name, ".") || strings.HasSuffix(name, " ") {
return fmt.Errorf("无效的文件名: %q", name)
}
if len(utf16.Encode([]rune(name))) > 255 {
return fmt.Errorf("文件名过长")
}
for _, r := range name {
if r < 32 || r == 127 || strings.ContainsRune(`<>:"/\|?*`, r) {
return fmt.Errorf("文件名包含非法字符: %q", name)
}
}
stem := strings.ToUpper(strings.SplitN(name, ".", 2)[0])
stem = strings.NewReplacer("¹", "1", "²", "2", "³", "3").Replace(stem)
switch stem {
case "CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$":
return fmt.Errorf("文件名是 Windows 保留名称: %q", name)
}
if len(stem) == 4 && (strings.HasPrefix(stem, "COM") || strings.HasPrefix(stem, "LPT")) && strings.ContainsRune("123456789", rune(stem[3])) {
return fmt.Errorf("文件名是 Windows 保留名称: %q", name)
}
return nil
}
+95
View File
@@ -0,0 +1,95 @@
package mod
import (
"fmt"
"io"
"io/fs"
"sort"
)
type Descriptor struct {
ID string `json:"id"`
Name string `json:"name"`
CanRead bool `json:"canRead"`
CanWrite bool `json:"canWrite"`
Operations []Operation `json:"operations"`
}
type Reader interface {
Detect(fs.FS) (bool, error)
Read(fs.FS) (Package, error)
}
type Writer interface {
Validate(Package) error
Files(Package) []OutputFile
Write(root string, pkg Package) error
}
type ArchiveWriter interface {
WriteArchive(io.Writer, Package) error
}
// OutputFile maps a physical output back to its removable package entry.
// An empty EntryPath marks generated metadata that must remain in the package.
type OutputFile struct {
Path string
EntryPath string
}
type Format struct {
Descriptor Descriptor
Reader Reader
Writer Writer
}
type Registry struct{ formats map[string]Format }
func NewRegistry() *Registry { return &Registry{formats: make(map[string]Format)} }
func DefaultRegistry() *Registry {
r := NewRegistry()
_ = r.Register(Format{
Descriptor: Descriptor{ID: "110USextend", Name: "110USextend", Operations: []Operation{ReplaceFile, MergeList, MergeStrings}},
Writer: Extend110Writer{},
Reader: Extend110Reader{},
})
_ = r.Register(Format{
Descriptor: Descriptor{ID: CN115Format, Name: CN115Format, Operations: []Operation{AddFile, ReplaceFile, MergeList, MergeStrings}},
Writer: CN115Writer{},
Reader: CN115Reader{},
})
return r
}
func (r *Registry) Register(f Format) error {
if f.Descriptor.ID == "" || (f.Reader == nil && f.Writer == nil) {
return fmt.Errorf("mod 格式需要 ID 和读取或写入能力")
}
if _, ok := r.formats[f.Descriptor.ID]; ok {
return fmt.Errorf("mod 格式已注册: %s", f.Descriptor.ID)
}
f.Descriptor.CanRead, f.Descriptor.CanWrite = f.Reader != nil, f.Writer != nil
f.Descriptor.Operations = append([]Operation(nil), f.Descriptor.Operations...)
r.formats[f.Descriptor.ID] = f
return nil
}
func (r *Registry) Get(id string) (Format, error) {
f, ok := r.formats[id]
if !ok {
return Format{}, fmt.Errorf("未知 mod 格式: %s", id)
}
f.Descriptor.Operations = append([]Operation(nil), f.Descriptor.Operations...)
return f, nil
}
func (r *Registry) List() []Descriptor {
result := make([]Descriptor, 0, len(r.formats))
for id := range r.formats {
f, _ := r.Get(id)
result = append(result, f.Descriptor)
}
sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID })
return result
}
+131
View File
@@ -0,0 +1,131 @@
package mod
import (
"archive/zip"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
"unicode/utf8"
"golang.org/x/text/encoding/simplifiedchinese"
)
const maxPayloadSize = 64 << 20
const maxPackageSize = 512 << 20
// OpenSource exposes a directory or a ZIP without extracting untrusted paths.
func OpenSource(name string, allowZIP bool) (fs.FS, io.Closer, error) {
info, err := os.Lstat(name)
if err != nil {
return nil, nil, err
}
if info.IsDir() {
root, err := os.OpenRoot(name)
if err != nil {
return nil, nil, err
}
return root.FS(), root, nil
}
if !allowZIP || !info.Mode().IsRegular() {
return nil, nil, fmt.Errorf("请选择 mod 目录或支持的 ZIP / .dnfmod 文件")
}
if ext := strings.ToLower(filepath.Ext(name)); ext != ".zip" && ext != ".dnfmod" {
return nil, nil, fmt.Errorf("MOD 压缩包仅支持 .zip 或 .dnfmod,不支持 RAR、7z、JAR")
}
z, err := zip.OpenReader(name)
if err != nil {
return nil, nil, fmt.Errorf("读取 MOD ZIP: %w", err)
}
fail := func(err error) (fs.FS, io.Closer, error) {
z.Close()
return nil, nil, err
}
if len(z.File) > 20000 {
return fail(fmt.Errorf("MOD ZIP 文件数量超出限制"))
}
var total uint64
seen := map[string]bool{}
files := map[string]bool{}
for _, f := range z.File {
if f.NonUTF8 && !utf8.ValidString(f.Name) {
decoded, err := simplifiedchinese.GB18030.NewDecoder().Bytes([]byte(f.Name))
if err != nil {
return fail(fmt.Errorf("ZIP 文件名编码无效"))
}
f.Name = string(decoded)
}
p := strings.TrimSuffix(f.Name, "/")
if !utf8.ValidString(p) || ValidatePath(p) != nil || protectedPackagePath(p) {
return fail(fmt.Errorf("ZIP 包含非法或敏感路径"))
}
key := strings.ToLower(p)
if seen[key] || (!f.Mode().IsRegular() && !f.Mode().IsDir()) {
return fail(fmt.Errorf("ZIP 路径冲突或包含非普通文件: %s", p))
}
seen[key] = true
files[key] = !f.FileInfo().IsDir()
if f.UncompressedSize64 > maxPayloadSize || total > maxPackageSize-f.UncompressedSize64 {
return fail(fmt.Errorf("MOD ZIP 解压大小超出限制"))
}
total += f.UncompressedSize64
}
for key := range seen {
for p := key; strings.Contains(p, "/"); {
p = p[:strings.LastIndex(p, "/")]
if files[p] {
return fail(fmt.Errorf("ZIP 文件与目录冲突: %s", key))
}
}
}
return z, z, nil
}
// readPackageFile checks every path component before opening, including parent
// symlinks in directory packages. The returned content is always bounded.
func readPackageFile(root fs.FS, name string, limit int64) ([]byte, error) {
if err := ValidatePath(name); err != nil {
return nil, err
}
if protectedPackagePath(name) {
return nil, fmt.Errorf("mod 包含敏感配置路径,拒绝读取")
}
dir := "."
parts := strings.Split(name, "/")
for i, part := range parts {
entries, err := fs.ReadDir(root, dir)
if err != nil {
return nil, err
}
var found fs.DirEntry
for _, entry := range entries {
if entry.Name() == part {
found = entry
break
}
}
if found == nil {
return nil, fmt.Errorf("mod 缺少文件: %s", name)
}
if found.Type()&fs.ModeSymlink != 0 || (i < len(parts)-1 && !found.IsDir()) || (i == len(parts)-1 && !found.Type().IsRegular()) {
return nil, fmt.Errorf("mod 路径不是普通文件或包含符号链接: %s", name)
}
if dir == "." {
dir = part
} else {
dir += "/" + part
}
}
f, err := root.Open(name)
if err != nil {
return nil, err
}
defer f.Close()
data, err := io.ReadAll(io.LimitReader(f, limit+1))
if err == nil && int64(len(data)) > limit {
return nil, fmt.Errorf("mod 文件超出大小限制: %s", name)
}
return data, err
}