From 560605a8e1dbb5a6e868dcc153ce4d873fe41f27 Mon Sep 17 00:00:00 2001 From: smail Date: Tue, 8 Sep 2026 21:08:36 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E9=85=8D=E7=BD=AEJSON?= =?UTF-8?q?=E7=BC=A9=E8=BF=9B=E6=8E=92=E7=89=88=E5=B9=B6=E4=BF=9D=E6=8C=81?= =?UTF-8?q?=E6=91=98=E8=A6=81=E4=B8=8E=E5=8E=86=E5=8F=B2=E5=85=BC=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/config/config.go | 4 +- backend/config/manager.go | 4 +- backend/config/manager_security_test.go | 8 +- backend/config/manager_v2_test.go | 10 +- backend/config/migrate.go | 2 +- backend/config/transaction.go | 8 +- test/backend/json_format_test.go | 161 ++++++++++++++++++++++++ test/run.mjs | 1 + test/tools/format-json/main.go | 112 +++++++++++++++++ test/tools/verify-json-format.mjs | 63 ++++++++++ 10 files changed, 360 insertions(+), 13 deletions(-) create mode 100644 test/backend/json_format_test.go create mode 100644 test/tools/format-json/main.go create mode 100644 test/tools/verify-json-format.mjs diff --git a/backend/config/config.go b/backend/config/config.go index b786d20..c1710d1 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -314,7 +314,7 @@ func writeProtectedConfigFile(name string, cfg *Config) error { } stored.SSLPrivateKeyPath = persistControlledSSLPath(stored.SSLPrivateKeyPath) stored.SSLPublicKeyPath = persistControlledSSLPath(stored.SSLPublicKeyPath) - data, err := json.Marshal(stored) + data, err := json.MarshalIndent(stored, "", " ") if err != nil { return errors.Wrap(err) } @@ -341,7 +341,7 @@ func readConfig() (*Config, error) { } cfgData, err := readConfigFile("config.json") if os.IsNotExist(err) { - data, marshalErr := json.Marshal(configData) + data, marshalErr := json.MarshalIndent(configData, "", " ") if marshalErr != nil { return nil, errors.Wrap(marshalErr) } diff --git a/backend/config/manager.go b/backend/config/manager.go index 4436114..9d36e73 100644 --- a/backend/config/manager.go +++ b/backend/config/manager.go @@ -157,7 +157,7 @@ func BackupConfig(root string, snapshot RuntimeSnapshot, now time.Time) (string, if err := pruneBackups(directory, now); err != nil { return "", err } - data, err := json.Marshal(snapshot) + data, err := json.MarshalIndent(snapshot, "", " ") if err != nil { return "", err } @@ -301,7 +301,7 @@ func writeRootConfig(root string, value RootConfig) error { return err } value = normalizeRootConfig(value) - data, err := json.Marshal(value) + data, err := json.MarshalIndent(value, "", " ") if err != nil { return err } diff --git a/backend/config/manager_security_test.go b/backend/config/manager_security_test.go index 0089a75..c7bbaba 100644 --- a/backend/config/manager_security_test.go +++ b/backend/config/manager_security_test.go @@ -103,8 +103,12 @@ func TestLoadManagerRewritesLegacyVersion(t *testing.T) { if err != nil { t.Fatal(err) } - if strings.Contains(string(rewritten), `"version"`) || !strings.Contains(string(rewritten), `"schemaVersion":2`) || !strings.Contains(string(rewritten), `"generation":7`) { - t.Fatalf("legacy configuration was not rewritten: %s", rewritten) + var saved RootConfig + if err := json.Unmarshal(rewritten, &saved); err != nil { + t.Fatal(err) + } + if strings.Contains(string(rewritten), `"version"`) || saved.SchemaVersion != CurrentSchemaVersion || saved.Generation != 7 { + t.Fatal("旧配置未正确更新版本和代次") } } diff --git a/backend/config/manager_v2_test.go b/backend/config/manager_v2_test.go index 886c329..c2b2954 100644 --- a/backend/config/manager_v2_test.go +++ b/backend/config/manager_v2_test.go @@ -503,8 +503,14 @@ func TestConfigManagerMailSectionAppliesBothPartitionsWithoutMailFile(t *testing if err != nil { t.Fatal(err) } - if !strings.Contains(string(data), `"generation":2`) { - t.Fatalf("%s was not updated to generation 2: %s", name, data) + var saved struct { + Meta SectionMeta `json:"meta"` + } + if err := json.Unmarshal(data, &saved); err != nil { + t.Fatal(err) + } + if saved.Meta.Generation != 2 { + t.Fatalf("%s 未更新至第二代配置", name) } } reloaded, err := NewConfigLoader(root, options).Load() diff --git a/backend/config/migrate.go b/backend/config/migrate.go index 93f0442..f90b0ad 100644 --- a/backend/config/migrate.go +++ b/backend/config/migrate.go @@ -264,7 +264,7 @@ func refreshLegacyMigrationRoot(runtime RuntimeConfig, files map[string][]byte) runtime.Root.Generation = runtime.Generation runtime.Root.SetupState = runtime.SetupState runtime.Root.Sections = references - data, err := json.Marshal(runtime.Root) + data, err := json.MarshalIndent(runtime.Root, "", " ") if err != nil { return err } diff --git a/backend/config/transaction.go b/backend/config/transaction.go index 7df442e..679c9cc 100644 --- a/backend/config/transaction.go +++ b/backend/config/transaction.go @@ -340,7 +340,7 @@ func marshalRuntimeFilesWithPluginSecrets(runtime RuntimeConfig, keepPluginSecre default: return nil, ErrInvalidSection } - data, err := json.Marshal(value) + data, err := json.MarshalIndent(value, "", " ") if err != nil { return nil, err } @@ -353,7 +353,7 @@ func marshalRuntimeFilesWithPluginSecrets(runtime RuntimeConfig, keepPluginSecre references[section] = SectionRef{File: file, Schema: schema, Generation: runtime.Generation, SHA256: SectionDigest(sectionBytes[section])} } runtime.Root.Sections = references - rootData, err := json.Marshal(runtime.Root) + rootData, err := json.MarshalIndent(runtime.Root, "", " ") if err != nil { return nil, err } @@ -470,7 +470,7 @@ func writeTransactionManifest(directory string, manifest TransactionManifest) er if err := validateTransactionManifest(manifest); err != nil { return err } - data, err := json.Marshal(manifest) + data, err := json.MarshalIndent(manifest, "", " ") if err != nil { return err } @@ -667,7 +667,7 @@ func (s *TransactionStore) createBackupAt(runtime RuntimeConfig, createdAt time. return "", err } } - manifestData, err := json.Marshal(manifest) + manifestData, err := json.MarshalIndent(manifest, "", " ") if err != nil { return "", err } diff --git a/test/backend/json_format_test.go b/test/backend/json_format_test.go new file mode 100644 index 0000000..2c89cc0 --- /dev/null +++ b/test/backend/json_format_test.go @@ -0,0 +1,161 @@ +package config + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" + "time" +) + +func requireIndentedJSON(t *testing.T, data []byte) { + t.Helper() + var expected bytes.Buffer + if err := json.Indent(&expected, data, "", " "); err != nil { + t.Fatal("配置不是有效 JSON") + } + if !bytes.Equal(data, expected.Bytes()) || !bytes.Contains(data, []byte("\n \"")) { + t.Fatal("配置未按两空格缩进分行保存") + } +} + +func TestRuntimeJSONFormattingPreservesValuesAndDigests(t *testing.T) { + runtime := validRuntimeConfig() + runtime.Plugins.Plugins["telegram_push"] = PluginInstance{ + Settings: json.RawMessage(`{"label":"中文配置","nested":{"enabled":true},"items":["邮件",9007199254740993]}`), + } + files, err := marshalRuntimeFiles(runtime) + if err != nil { + t.Fatal(err) + } + for _, data := range files { + requireIndentedJSON(t, data) + } + var root RootConfig + if err := json.Unmarshal(files["config.json"], &root); err != nil { + t.Fatal(err) + } + for section, reference := range root.Sections { + name, _ := FixedSectionFile(section) + if reference.SHA256 != SectionDigest(files[name]) { + t.Fatal("根配置未使用格式化后的摘要") + } + } + var plugins PluginsSectionConfig + if err := json.Unmarshal(files["plugins.json"], &plugins); err != nil { + t.Fatal(err) + } + var compact bytes.Buffer + if err := json.Compact(&compact, plugins.Plugins["telegram_push"].Settings); err != nil { + t.Fatal(err) + } + if !bytes.Equal(compact.Bytes(), runtime.Plugins.Plugins["telegram_push"].Settings) { + t.Fatal("格式化改变了中文、嵌套字段或大整数") + } +} + +func TestRuntimeJSONFormattingLoadsCompactHistoryAndPersistsIndented(t *testing.T) { + for _, deployment := range []bool{false, true} { + name := "legacy" + if deployment { + name = "deployment" + } + t.Run(name, func(t *testing.T) { + root := t.TempDir() + if deployment { + if err := InitializeDeployment(root); err != nil { + t.Fatal(err) + } + } + current := validRuntimeConfig() + files, err := marshalRuntimeFiles(current) + if err != nil { + t.Fatal(err) + } + var oldRoot RootConfig + if err := json.Unmarshal(files["config.json"], &oldRoot); err != nil { + t.Fatal(err) + } + // 模拟真实的旧版单行文件,仍使用原字节的摘要。 + for section, reference := range oldRoot.Sections { + var compact bytes.Buffer + if err := json.Compact(&compact, files[reference.File]); err != nil { + t.Fatal(err) + } + files[reference.File] = compact.Bytes() + reference.SHA256 = SectionDigest(compact.Bytes()) + oldRoot.Sections[section] = reference + } + files["config.json"], err = json.Marshal(oldRoot) + if err != nil { + t.Fatal(err) + } + directory, err := secureManagerConfigDirectory(root) + if err != nil { + t.Fatal(err) + } + for filename, data := range files { + if err := os.WriteFile(filepath.Join(directory, filename), data, 0600); err != nil { + t.Fatal(err) + } + } + validation := RuntimeValidationOptions{HasSuperAdmin: true} + loader := NewConfigLoader(root, ConfigLoaderOptions{Validation: validation}) + loaded, err := loader.Load() + if err != nil || loaded.Generation != current.Generation { + t.Fatalf("旧版单行配置不能读取:%v", err) + } + store := NewTransactionStore(root, validation) + if _, err := store.CreateBackup(loaded); err != nil { + t.Fatal(err) + } + if _, err := store.LoadBackup(loaded.Generation); err != nil { + t.Fatal(err) + } + candidate := cloneRuntimeConfig(loaded) + setCandidateGeneration(&candidate, loaded.Generation+1) + if err := store.Commit(loaded, candidate); err != nil { + t.Fatal(err) + } + if _, err := loader.Load(); err != nil { + t.Fatal(err) + } + for filename := range files { + data, err := os.ReadFile(filepath.Join(directory, filename)) + if err != nil { + t.Fatal(err) + } + requireIndentedJSON(t, data) + } + entries, err := os.ReadDir(directory) + if err != nil || len(entries) != 5 { + t.Fatal("格式化不应生成额外配置文件") + } + }) + } +} + +func TestAuditRemainsJSONLAfterConfigFormatting(t *testing.T) { + writer, err := NewFileAuditWriter(t.TempDir()) + if err != nil { + t.Fatal(err) + } + for index := 0; index < 2; index++ { + if err := writer.WriteConfigAudit(nil, ConfigAuditEvent{ + Operation: ConfigOperationUpdate, Section: SectionRoot, + ActorID: "1", ActorRole: "super_admin", FromGeneration: 1, + ToGeneration: 2, Result: "success", CreatedAt: time.Now().UTC(), + }); err != nil { + t.Fatal(err) + } + } + data, err := os.ReadFile(writer.path) + if err != nil { + t.Fatal(err) + } + lines := bytes.Split(bytes.TrimSpace(data), []byte("\n")) + if len(lines) != 2 || !json.Valid(lines[0]) || !json.Valid(lines[1]) { + t.Fatal("本次排版不能改变逐行审计格式") + } +} diff --git a/test/run.mjs b/test/run.mjs index d9f61ff..cc82d33 100644 --- a/test/run.mjs +++ b/test/run.mjs @@ -49,6 +49,7 @@ if ((suite === 'all' || suite === 'backend') && result === 0) { const args = extra[0] === '--' ? extra.slice(1) : extra // 独立回归源码集中放在 test/backend,通过 overlay 保留同包测试语义。 const replacements = { + 'backend/config/json_format_test.go': 'test/backend/json_format_test.go', 'backend/config/deployment_state_test.go': 'test/backend/deployment_state_test.go', 'backend/config/ssl_retention_test.go': 'test/backend/ssl_retention_test.go', 'backend/utils/runtimepath/layout_test.go': 'test/backend/runtime_layout_test.go', diff --git a/test/tools/format-json/main.go b/test/tools/format-json/main.go new file mode 100644 index 0000000..ce050ed --- /dev/null +++ b/test/tools/format-json/main.go @@ -0,0 +1,112 @@ +// 本工具仅在停服后调整指定配置目录的空白排版,并同步原有文件摘要。 +package main + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "path/filepath" + + "git.pcux.cn/xuans/Smail/utils/atomicfile" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run() error { + directory := flag.String("config", "", "已停止后端的配置目录") + backup := flag.String("backup", "", "保存原字节的独立备份目录") + apply := flag.Bool("apply", false, "确认停服,执行排版;默认只检查") + flag.Parse() + if !filepath.IsAbs(*directory) || !filepath.IsAbs(*backup) { + return errors.New("配置及备份目录必须使用明确的绝对路径") + } + info, err := os.Lstat(*directory) + if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return errors.New("配置目录不可用或是链接") + } + names := []string{"smtp.json", "email.json", "ssl.json", "plugins.json", "config.json"} + original, formatted := map[string][]byte{}, map[string][]byte{} + for _, name := range names { + path := filepath.Join(*directory, name) + info, err := os.Lstat(path) + if err != nil || !info.Mode().IsRegular() || info.Size() > 4<<20 { + return errors.New("配置文件不可用、过大或不是普通文件") + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + var result bytes.Buffer + // 不解析再编码业务值,保留中文、密文、数字及转义的原始字节。 + if err := json.Indent(&result, data, "", " "); err != nil { + return fmt.Errorf("%s 不是有效 JSON,未写入", name) + } + original[name], formatted[name] = data, result.Bytes() + } + var root struct { + Sections map[string]struct { + File string `json:"file"` + SHA256 string `json:"sha256"` + } `json:"sections"` + } + if err := json.Unmarshal(original["config.json"], &root); err != nil || len(root.Sections) != 4 { + return errors.New("仅支持已完成分区的五文件配置") + } + for _, name := range names[:4] { + reference := root.Sections[name[:len(name)-5]] + if reference.File != name || reference.SHA256 != digest(original[name]) { + return fmt.Errorf("%s 原有摘要不匹配,未写入", name) + } + old := []byte(`"sha256": "` + reference.SHA256 + `"`) + next := []byte(`"sha256": "` + digest(formatted[name]) + `"`) + if bytes.Count(formatted["config.json"], old) != 1 { + return errors.New("根配置摘要位置不唯一,未写入") + } + formatted["config.json"] = bytes.Replace(formatted["config.json"], old, next, 1) + } + changed := false + for _, name := range names { + changed = changed || !bytes.Equal(original[name], formatted[name]) + } + if !*apply || !changed { + fmt.Printf("检查通过:五个 JSON 可读取,是否需要排版:%t。\n", changed) + return nil + } + // 备份目录必须不存在,拒绝覆盖之前的备份。 + if err := os.Mkdir(*backup, 0700); err != nil { + return errors.New("无法创建独立备份目录,未改写配置") + } + for _, name := range names { + if err := atomicfile.Write(filepath.Join(*backup, name), original[name], 0600); err != nil { + return errors.New("备份未完成,未改写配置") + } + } + for _, name := range names { + data, err := os.ReadFile(filepath.Join(*directory, name)) + if err != nil || !bytes.Equal(data, original[name]) { + return errors.New("备份期间配置发生变化,未改写配置") + } + } + for _, name := range names { + if err := atomicfile.Write(filepath.Join(*directory, name), formatted[name], 0600); err != nil { + return errors.New("写入未完成,请保持停服并使用本次备份恢复五个文件") + } + } + fmt.Println("五个 JSON 已分行缩进,摘要已同步;配置值、代次及密文未改变。") + return nil +} + +func digest(data []byte) string { + value := sha256.Sum256(data) + return hex.EncodeToString(value[:]) +} diff --git a/test/tools/verify-json-format.mjs b/test/tools/verify-json-format.mjs new file mode 100644 index 0000000..c34d05e --- /dev/null +++ b/test/tools/verify-json-format.mjs @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { readFileSync, readdirSync, writeFileSync } from 'node:fs' +import path from 'node:path' + +const [workspace, evidence] = process.argv.slice(2) +if (!workspace || !evidence) throw new Error('请指定项目根目录和本次验证目录') +const digest = data => createHash('sha256').update(data).digest('hex') +const read = filename => readFileSync(filename, 'utf8') +const names = ['config.json', 'smtp.json', 'email.json', 'ssl.json', 'plugins.json'] + +// 只忽略字符串外的 JSON 空白,数字精度、中文、转义及字段顺序都必须保持。 +function compact(raw) { + JSON.parse(raw) + let quoted = false + let escaped = false + let result = '' + for (const character of raw) { + if (quoted) { + result += character + if (escaped) escaped = false + else if (character === '\\') escaped = true + else if (character === '"') quoted = false + } else if (character === '"') { + quoted = true + result += character + } else if (!' \t\r\n'.includes(character)) result += character + } + return result +} + +const report = { checkedAt: new Date().toISOString(), files: [] } +for (const [relative, backup] of [['config', 'root-config'], ['yunx-test/config', 'runtime-config']]) { + const directory = path.join(workspace, relative) + assert.deepEqual(readdirSync(directory).sort(), [...names].sort(), '配置目录出现额外文件') + const oldRoot = JSON.parse(read(path.join(evidence, backup, 'config.json'))) + const newRoot = JSON.parse(read(path.join(directory, 'config.json'))) + for (const name of names) { + const oldRaw = read(path.join(evidence, backup, name)) + const newRaw = read(path.join(directory, name)) + assert(!newRaw.startsWith('\ufeff'), '配置不能含 BOM') + assert(newRaw.includes('\n "'), '配置未按两空格缩进分行') + let expected = compact(oldRaw) + if (name === 'config.json') { + for (const [section, reference] of Object.entries(oldRoot.sections)) { + expected = expected.replace(`"sha256":"${reference.sha256}"`, `"sha256":"${newRoot.sections[section].sha256}"`) + } + } else { + const section = name.slice(0, -5) + assert.equal(newRoot.sections[section].sha256, digest(Buffer.from(newRaw)), '新文件摘要不匹配') + } + // 不输出配置原文或断言两侧内容,避免凭据进入测试日志。 + assert(expected === compact(newRaw), `${relative}/${name} 内容发生变化`) + report.files.push({ file: `${relative}/${name}`, lines: newRaw.split('\n').length, valuesUnchanged: true }) + } +} +const before = JSON.parse(read(path.join(evidence, 'before.json')).replace(/^\ufeff/, '')) +const audit = readFileSync(path.join(workspace, 'yunx-test/log/config-audit-v2.jsonl')) +report.auditUnchanged = digest(audit).toUpperCase() === before.auditSHA256 +assert(report.auditUnchanged, '审计原始文件发生变化') +writeFileSync(path.join(evidence, 'verification.json'), JSON.stringify(report, null, 2) + '\n') +console.log('验证通过:10 个 JSON 仅变更空白和对应摘要;中文、密文、数字、版本及审计原字节保持不变。') +for (const file of report.files) console.log(`${file.file}:${file.lines} 行`)