This commit is contained in:
parent
b705f00b22
commit
560605a8e1
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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("旧配置未正确更新版本和代次")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
161
test/backend/json_format_test.go
Normal file
161
test/backend/json_format_test.go
Normal 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("本次排版不能改变逐行审计格式")
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
|
||||
112
test/tools/format-json/main.go
Normal file
112
test/tools/format-json/main.go
Normal 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[:])
|
||||
}
|
||||
63
test/tools/verify-json-format.mjs
Normal file
63
test/tools/verify-json-format.mjs
Normal 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} 行`)
|
||||
Loading…
Reference in New Issue
Block a user