统一配置JSON缩进排版并保持摘要与历史兼容
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-08 21:08:36 +08:00
parent b705f00b22
commit 560605a8e1
10 changed files with 360 additions and 13 deletions

View File

@ -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)
}

View File

@ -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
}

View File

@ -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("旧配置未正确更新版本和代次")
}
}

View File

@ -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()

View File

@ -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
}

View File

@ -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
}

View File

@ -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("本次排版不能改变逐行审计格式")
}
}

View File

@ -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',

View File

@ -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[:])
}

View File

@ -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} 行`)