修复配置保存提交边界并补充失败回归测试
This commit is contained in:
parent
8443c712ff
commit
5f3d3a0a22
@ -410,21 +410,7 @@ func (c Config) Save(path string) error {
|
||||
return fmt.Errorf("编码配置失败:%w", err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
tmpPath := path + ".tmp"
|
||||
if err := os.WriteFile(tmpPath, data, 0o600); err != nil {
|
||||
return fmt.Errorf("写入临时配置文件失败:%w", err)
|
||||
}
|
||||
if err := os.Rename(tmpPath, path); err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
return fmt.Errorf("替换配置文件失败:%w", err)
|
||||
}
|
||||
if err := os.Chmod(path, 0o600); err != nil {
|
||||
return fmt.Errorf("设置配置文件权限失败:%w", err)
|
||||
}
|
||||
if err := applyLinuxServiceOwnership(filepath.Dir(path), path); err != nil {
|
||||
return fmt.Errorf("设置配置文件归属失败:%w", err)
|
||||
}
|
||||
return nil
|
||||
return saveConfigFile(path, data, defaultConfigSaveOperations())
|
||||
}
|
||||
|
||||
// applyLinuxServiceOwnership 在 root 初始化配置时,将配置目录和文件交给 Scert 服务用户管理。
|
||||
|
||||
75
backend/internal/config/save.go
Normal file
75
backend/internal/config/save.go
Normal file
@ -0,0 +1,75 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type configTempFile interface {
|
||||
io.Writer
|
||||
Name() string
|
||||
Chmod(os.FileMode) error
|
||||
Sync() error
|
||||
Close() error
|
||||
}
|
||||
|
||||
type configSaveOperations struct {
|
||||
createTemp func(string, string) (configTempFile, error)
|
||||
ownership func(string, string) error
|
||||
replace func(string, string) error
|
||||
}
|
||||
|
||||
func defaultConfigSaveOperations() configSaveOperations {
|
||||
return configSaveOperations{
|
||||
createTemp: func(directory, pattern string) (configTempFile, error) {
|
||||
return os.CreateTemp(directory, pattern)
|
||||
},
|
||||
ownership: applyLinuxServiceOwnership,
|
||||
replace: os.Rename,
|
||||
}
|
||||
}
|
||||
|
||||
// saveConfigFile 以同目录文件替换作为提交点,提交之后不再执行可能报错的操作。
|
||||
// 目录权限和归属调整仍可能留下元数据变化;此处不保证断电后的目录项持久性。
|
||||
func saveConfigFile(path string, data []byte, operations configSaveOperations) error {
|
||||
directory := filepath.Dir(path)
|
||||
temporary, err := operations.createTemp(directory, "."+filepath.Base(path)+"-*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建临时配置文件失败:%w", err)
|
||||
}
|
||||
closed, committed := false, false
|
||||
defer func() {
|
||||
if !closed {
|
||||
_ = temporary.Close()
|
||||
}
|
||||
if !committed {
|
||||
_ = os.Remove(temporary.Name())
|
||||
}
|
||||
}()
|
||||
if written, err := temporary.Write(data); err != nil {
|
||||
return fmt.Errorf("写入临时配置文件失败:%w", err)
|
||||
} else if written != len(data) {
|
||||
return fmt.Errorf("写入临时配置文件失败:%w", io.ErrShortWrite)
|
||||
}
|
||||
if err := temporary.Chmod(0o600); err != nil {
|
||||
return fmt.Errorf("设置配置文件权限失败:%w", err)
|
||||
}
|
||||
if err := operations.ownership(directory, temporary.Name()); err != nil {
|
||||
return fmt.Errorf("设置配置文件归属失败:%w", err)
|
||||
}
|
||||
if err := temporary.Sync(); err != nil {
|
||||
return fmt.Errorf("同步临时配置文件失败:%w", err)
|
||||
}
|
||||
err = temporary.Close()
|
||||
closed = true
|
||||
if err != nil {
|
||||
return fmt.Errorf("关闭临时配置文件失败:%w", err)
|
||||
}
|
||||
if err := operations.replace(temporary.Name(), path); err != nil {
|
||||
return fmt.Errorf("替换配置文件失败:%w", err)
|
||||
}
|
||||
committed = true
|
||||
return nil
|
||||
}
|
||||
197
backend/internal/config/save_test.go
Normal file
197
backend/internal/config/save_test.go
Normal file
@ -0,0 +1,197 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type failingConfigFile struct {
|
||||
*os.File
|
||||
stage string
|
||||
err error
|
||||
}
|
||||
|
||||
func (file failingConfigFile) Write(data []byte) (int, error) {
|
||||
if file.stage == "write" {
|
||||
return 0, file.err
|
||||
}
|
||||
if file.stage == "short_write" {
|
||||
return len(data) - 1, nil
|
||||
}
|
||||
return file.File.Write(data)
|
||||
}
|
||||
|
||||
func (file failingConfigFile) Chmod(mode os.FileMode) error {
|
||||
if file.stage == "chmod" {
|
||||
return file.err
|
||||
}
|
||||
return file.File.Chmod(mode)
|
||||
}
|
||||
|
||||
func (file failingConfigFile) Sync() error {
|
||||
if file.stage == "sync" {
|
||||
return file.err
|
||||
}
|
||||
return file.File.Sync()
|
||||
}
|
||||
|
||||
func (file failingConfigFile) Close() error {
|
||||
err := file.File.Close()
|
||||
if file.stage == "close" {
|
||||
return file.err
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func TestSaveConfigFailuresPreserveDestination(t *testing.T) {
|
||||
for _, existing := range []bool{false, true} {
|
||||
for _, stage := range []string{"create", "write", "short_write", "chmod", "ownership", "sync", "close", "replace"} {
|
||||
name := stage + "/new"
|
||||
if existing {
|
||||
name = stage + "/existing"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
directory := t.TempDir()
|
||||
path := filepath.Join(directory, "scert.json")
|
||||
original := []byte("original configuration\n")
|
||||
if existing {
|
||||
if err := os.WriteFile(path, original, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
injected := errors.New("injected save failure")
|
||||
operations := defaultConfigSaveOperations()
|
||||
operations.createTemp = func(dir, pattern string) (configTempFile, error) {
|
||||
if stage == "create" {
|
||||
return nil, injected
|
||||
}
|
||||
file, err := os.CreateTemp(dir, pattern)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return failingConfigFile{File: file, stage: stage, err: injected}, nil
|
||||
}
|
||||
operations.ownership = func(string, string) error {
|
||||
if stage == "ownership" {
|
||||
return injected
|
||||
}
|
||||
return nil
|
||||
}
|
||||
replaceCalls := 0
|
||||
operations.replace = func(from, to string) error {
|
||||
replaceCalls++
|
||||
if stage == "replace" {
|
||||
return injected
|
||||
}
|
||||
return os.Rename(from, to)
|
||||
}
|
||||
expected := injected
|
||||
if stage == "short_write" {
|
||||
expected = io.ErrShortWrite
|
||||
}
|
||||
if err := saveConfigFile(path, []byte("new configuration\n"), operations); !errors.Is(err, expected) {
|
||||
t.Fatalf("预期保存失败且保留原因,实际:%v", err)
|
||||
}
|
||||
if stage != "replace" && replaceCalls != 0 {
|
||||
t.Fatal("准备阶段失败不得进入提交步骤")
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if existing {
|
||||
if err != nil || string(data) != string(original) {
|
||||
t.Fatalf("失败保存不得改变旧配置:%q,%v", data, err)
|
||||
}
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("首次保存失败不得生成正式配置:%v", err)
|
||||
}
|
||||
assertNoConfigTemporaryFiles(t, directory)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveConfigPreparesBeforeReplacement(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
path := filepath.Join(directory, "scert.json")
|
||||
if err := os.WriteFile(path, []byte("old"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 历史固定名称临时文件不属于本次保存,必须原样保留。
|
||||
if err := os.WriteFile(path+".tmp", []byte("unrelated"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
operations := defaultConfigSaveOperations()
|
||||
prepared := false
|
||||
operations.ownership = func(dir, temporary string) error {
|
||||
if dir != directory || filepath.Dir(temporary) != directory || temporary == path || temporary == path+".tmp" {
|
||||
t.Fatal("权限准备只能作用于本次同目录随机临时文件")
|
||||
}
|
||||
prepared = true
|
||||
return nil
|
||||
}
|
||||
operations.replace = func(from, to string) error {
|
||||
if !prepared || to != path {
|
||||
t.Fatal("替换必须位于权限和归属准备之后")
|
||||
}
|
||||
old, err := os.ReadFile(path)
|
||||
if err != nil || string(old) != "old" {
|
||||
t.Fatal("提交前旧配置不得变化")
|
||||
}
|
||||
info, err := os.Stat(from)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("提交前临时文件权限不正确:%v", info.Mode())
|
||||
}
|
||||
return os.Rename(from, to)
|
||||
}
|
||||
if err := saveConfigFile(path, []byte("new"), operations); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil || string(data) != "new" {
|
||||
t.Fatalf("提交后正式内容不正确:%q,%v", data, err)
|
||||
}
|
||||
data, err = os.ReadFile(path + ".tmp")
|
||||
if err != nil || string(data) != "unrelated" {
|
||||
t.Fatal("不得覆盖或清理历史临时文件")
|
||||
}
|
||||
assertNoConfigTemporaryFiles(t, directory)
|
||||
}
|
||||
|
||||
func TestSaveReplacesExistingConfiguration(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
path := filepath.Join(directory, "scert.json")
|
||||
cfg := Default(directory)
|
||||
if err := cfg.Save(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg.Server.Port = 9443
|
||||
if err := cfg.Save(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := Load(path)
|
||||
if err != nil || loaded.Server.Port != 9443 {
|
||||
t.Fatalf("覆盖保存后配置读取不正确:%v", err)
|
||||
}
|
||||
assertNoConfigTemporaryFiles(t, directory)
|
||||
}
|
||||
|
||||
func assertNoConfigTemporaryFiles(t *testing.T, directory string) {
|
||||
t.Helper()
|
||||
entries, err := os.ReadDir(directory)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if strings.HasPrefix(entry.Name(), ".scert.json-") {
|
||||
t.Fatalf("本次保存遗留临时文件:%s", entry.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user