DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user